feat(editor): merge multi-database query results into one view
This commit is contained in:
1 parent
9d07708617
commit
8b822e974b
31 files changed
+3085
-87
No files matched your search
@@ -18,6 +18,8 @@ import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
|
||||
import type { MultiDbExecutionContext } from "@/composables/useMultiDbExecution";
|
||||
import type { MultiDbExecutionTarget } from "@/types/sqlExecution";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { enforceRightSidebarPanelExclusivity, RIGHT_SIDEBAR_PANEL_IDS, transitionRightSidebarPanels, useSettingsStore, type RightSidebarPanelId, type RightSidebarPanelState } from "@/stores/settingsStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
@@ -1005,7 +1007,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; manualTransaction?: boolean }; isCancellationRequested: () => boolean }) {
|
||||
async function executeMultiDbTarget(input: { target: MultiDbExecutionTarget; sourceTabId: string; sql: string; scopeId: string; context: Readonly<MultiDbExecutionContext>; 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") };
|
||||
@@ -1023,6 +1025,8 @@ async function executeMultiDbTarget(input: { target: { connectionId: string; cat
|
||||
manualTransaction: input.context.manualTransaction,
|
||||
blockDangerousRedisCommands: blockDangerousRedisCommands.value,
|
||||
targetLabel: multiExecuteTargetLabel(input.target),
|
||||
// The confirmation must list the whole fan-out, not just this target.
|
||||
batchTargetLabels: input.context.targets.map(multiExecuteTargetLabel),
|
||||
scopeId: input.scopeId,
|
||||
isCancellationRequested: input.isCancellationRequested,
|
||||
targetContext: sqlExecutionTargetCapabilities(connection)?.provider.toExecutionContext(input.target, connection),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, Check, CheckSquare, ChevronDown, ChevronRight, FolderOpen, Layers, Loader2, MinusSquare, Pencil, Play, RefreshCw, Search, Settings2, Square, Trash2, X } from "@lucide/vue";
|
||||
import { AlertTriangle, Check, CheckSquare, ChevronDown, ChevronRight, Download, FolderOpen, Layers, Loader2, MinusSquare, Pencil, Play, RefreshCw, Search, Settings2, Square, Trash2, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -9,11 +9,13 @@ import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useMultiDbExecution, type MultiDbExecutionAdapter, type MultiDbExecutionMode } from "@/composables/useMultiDbExecution";
|
||||
import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker";
|
||||
import { useMultiDbTargetSelection, type MultiDbTargetCatalogOption, type SqlExecutionTargetValidationReason } from "@/composables/useMultiDbTargetSelection";
|
||||
import { useSqlExecutionTargetGroupStore } from "@/stores/sqlExecutionTargetGroupStore";
|
||||
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
|
||||
import { dedupeMultiDbExecutionTargets, multiDbExecutionTargetKey, type MultiDbExecutionTarget, type MultiDbExecutionItemStatus, type SqlExecutionTargetGroup, type SqlExecutionTargetValidation } from "@/types/sqlExecution";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
@@ -40,6 +42,7 @@ const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { addMultiDbExecutionTask, updateMultiDbExecutionTask, registerTaskCancelHandler, unregisterTaskCancelHandler } = useExportTracker();
|
||||
const targetGroupStore = useSqlExecutionTargetGroupStore();
|
||||
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
|
||||
const targetSelection = useMultiDbTargetSelection(computed(() => props.databaseType));
|
||||
const compatibleConnections = targetSelection.compatibleConnections;
|
||||
|
||||
@@ -123,11 +126,137 @@ const progressCounts = computed(() => {
|
||||
pendingCommit: items.filter((item) => item.status === "pending_commit").length,
|
||||
};
|
||||
});
|
||||
const elapsedMs = computed(() => {
|
||||
/**
|
||||
* A target parked on the danger confirmation is waiting for the operator, not
|
||||
* executing: that wait must not count as batch time or as target time.
|
||||
*/
|
||||
const awaitingConfirmation = computed(() => {
|
||||
const pending = sqlExecutionDangerStore.pending;
|
||||
return pending !== undefined && pending.scopeId !== undefined && pending.scopeId === batch.value?.id;
|
||||
});
|
||||
const confirmationWaitMs = ref(0);
|
||||
/** Value shown while a prompt is on screen, so the clock visibly stands still. */
|
||||
const frozenElapsedMs = ref<number>();
|
||||
let confirmationWaitStartedAt: number | undefined;
|
||||
/** Batch time with every answered prompt already taken out. */
|
||||
function elapsedWithoutWaits(): number {
|
||||
const current = batch.value;
|
||||
if (!current) return 0;
|
||||
return current.durationMs ?? Math.max(0, (current.completedAt ?? currentTime.value) - current.startedAt);
|
||||
});
|
||||
const total = current.durationMs ?? Math.max(0, (current.completedAt ?? currentTime.value) - current.startedAt);
|
||||
return Math.max(0, total - confirmationWaitMs.value);
|
||||
}
|
||||
watch(
|
||||
() => [batch.value?.id, awaitingConfirmation.value] as const,
|
||||
([batchId, waiting], [previousBatchId, wasWaiting]) => {
|
||||
if (batchId !== previousBatchId) {
|
||||
confirmationWaitMs.value = 0;
|
||||
confirmationWaitStartedAt = undefined;
|
||||
frozenElapsedMs.value = undefined;
|
||||
}
|
||||
if (waiting) {
|
||||
confirmationWaitStartedAt = Date.now();
|
||||
frozenElapsedMs.value = elapsedWithoutWaits();
|
||||
return;
|
||||
}
|
||||
if (wasWaiting && confirmationWaitStartedAt !== undefined) {
|
||||
confirmationWaitMs.value += Date.now() - confirmationWaitStartedAt;
|
||||
confirmationWaitStartedAt = undefined;
|
||||
frozenElapsedMs.value = undefined;
|
||||
}
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
/** Label of the target whose confirmation is on screen, when one is parked. */
|
||||
const awaitingTargetLabel = computed(() => (awaitingConfirmation.value ? sqlExecutionDangerStore.pending?.targetLabel : undefined));
|
||||
function isAwaitingTarget(item: { target: MultiDbExecutionTarget; status: MultiDbExecutionItemStatus }): boolean {
|
||||
if (item.status !== "running" || awaitingTargetLabel.value === undefined) return false;
|
||||
return targetLabel(item.target) === awaitingTargetLabel.value;
|
||||
}
|
||||
const elapsedMs = computed(() => (awaitingConfirmation.value && frozenElapsedMs.value !== undefined ? frozenElapsedMs.value : elapsedWithoutWaits()));
|
||||
|
||||
/** Merged multi-source view over the results this batch produced. */
|
||||
const mergeViewOpen = ref(false);
|
||||
const mergeViewRef = ref<InstanceType<typeof MultiSourceMergeView>>();
|
||||
const mergeItems = computed(() =>
|
||||
(batch.value?.items ?? []).map((item) => ({
|
||||
key: item.id,
|
||||
label: targetLabel(item.target),
|
||||
result: item.result,
|
||||
status: item.status,
|
||||
durationMs: item.durationMs,
|
||||
errorMessage: item.errorMessage,
|
||||
...(item.transaction ? { transaction: { canCommit: item.transaction.canCommit, settling: item.settling === true } } : {}),
|
||||
})),
|
||||
);
|
||||
/** Targets whose result is worth merging, settled or still inside a transaction. */
|
||||
const mergedResultCount = computed(() => (batch.value?.items ?? []).filter((item) => item.status === "success" || item.status === "pending_commit").length);
|
||||
/** Merging needs more than one source; the view also reports failed targets. */
|
||||
const canShowMergedResults = computed(() => !isExecuting.value && (batch.value?.items.length ?? 0) >= 2);
|
||||
const batchSettled = computed(() => batch.value !== undefined && batch.value.status !== "running" && batch.value.status !== "cancelling");
|
||||
/** The batch opened a transaction per target, so writes stay reviewable. */
|
||||
const mergeBatchIsTransactional = computed(() => batch.value?.context.manualTransaction === true || (batch.value?.items ?? []).some((item) => item.transaction !== undefined || item.status === "pending_commit" || item.status === "rolled_back"));
|
||||
|
||||
/**
|
||||
* Settles one target's transaction through the batch's own state machine.
|
||||
* `commit` writes the changes of that target only; a target that errored has
|
||||
* nothing to commit and is rolled back by the executor instead.
|
||||
*/
|
||||
async function settleMergeTargetTransaction(itemId: string, action: "commit" | "rollback"): Promise<void> {
|
||||
const item = batch.value?.items.find((candidate) => candidate.id === itemId);
|
||||
if (!item?.transaction || item.settling) return;
|
||||
const settled = await execution.finishTransaction(itemId, action);
|
||||
if (!settled) toast(item.errorMessage ? t("multiDbExecute.txnActionFailed", { message: item.errorMessage }) : t("common.failed"), 5000);
|
||||
}
|
||||
|
||||
/** Commits every target that succeeded, leaving errored ones for a decision. */
|
||||
async function commitAllMergeTargets(): Promise<void> {
|
||||
const targets = (batch.value?.items ?? []).filter((item) => item.transaction?.canCommit === true);
|
||||
for (const item of targets) await settleMergeTargetTransaction(item.id, "commit");
|
||||
}
|
||||
|
||||
/** Discards every still-open transaction of the batch. */
|
||||
async function rollbackAllMergeTargets(): Promise<void> {
|
||||
const targets = (batch.value?.items ?? []).filter((item) => item.transaction !== undefined);
|
||||
for (const item of targets) await settleMergeTargetTransaction(item.id, "rollback");
|
||||
}
|
||||
|
||||
function revealMergedView(): void {
|
||||
mergeViewOpen.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs the batch's statement on one target only — the merged view uses it to
|
||||
* retry a target that failed without redoing the ones that succeeded.
|
||||
*/
|
||||
async function rerunMergeTarget(itemId: string): Promise<void> {
|
||||
await execution.retry(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished batch that produced data on several targets is worth looking at,
|
||||
* so reopening the dialog lands directly in the merged view instead of making
|
||||
* the user click through the progress list again.
|
||||
*/
|
||||
function revealMergedViewWhenSettled(): void {
|
||||
if (mergeViewOpen.value || !canShowMergedResults.value || !batchSettled.value) return;
|
||||
revealMergedView();
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch may finish while the dialog is minimized to the background task
|
||||
* list. Announce it with an action that jumps straight into the merged view.
|
||||
*/
|
||||
function announceMergedViewInBackground(current: NonNullable<typeof batch.value>): void {
|
||||
if (open.value || current.cancelRequested) return;
|
||||
if (current.status !== "completed" || mergedResultCount.value < 2) return;
|
||||
toast(t("multiDbExecute.backgroundBatchDone", { count: mergedResultCount.value }), 8000, {
|
||||
label: t("multiDbExecute.mergeResults"),
|
||||
onClick: () => {
|
||||
open.value = true;
|
||||
revealMergedView();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function syncBackgroundTask(current: NonNullable<typeof batch.value>): void {
|
||||
const items = current.items;
|
||||
@@ -169,10 +298,14 @@ watch(
|
||||
addMultiDbExecutionTask(current.id, t("multiDbExecute.title"), current.sourceTabId, () => {
|
||||
open.value = true;
|
||||
});
|
||||
registerTaskCancelHandler(current.id, () => execution.cancel());
|
||||
}
|
||||
syncBackgroundTask(current);
|
||||
if (current.status === "completed" || current.status === "cancelled") unregisterTaskCancelHandler(current.id);
|
||||
if (current.status === "running") {
|
||||
registerTaskCancelHandler(current.id, () => execution.cancel());
|
||||
} else if (current.status === "completed" || current.status === "cancelled") {
|
||||
unregisterTaskCancelHandler(current.id);
|
||||
announceMergedViewInBackground(current);
|
||||
}
|
||||
},
|
||||
{ deep: true, flush: "sync" },
|
||||
);
|
||||
@@ -675,6 +808,7 @@ function openForNewBatch(): void {
|
||||
executionMode.value = "serial";
|
||||
manualTransaction.value = props.initialManualTransaction === true && supportsManualTransaction.value;
|
||||
trackedBatchId = undefined;
|
||||
mergeViewOpen.value = false;
|
||||
manageGroups.value = false;
|
||||
selectedGroupId.value = undefined;
|
||||
searchText.value = "";
|
||||
@@ -733,10 +867,15 @@ watch(
|
||||
watch(
|
||||
() => [open.value, props.launchId] as const,
|
||||
([value, launchId]) => {
|
||||
if (value && launchId !== initializedLaunchId) {
|
||||
if (!value) return;
|
||||
if (launchId !== initializedLaunchId) {
|
||||
initializedLaunchId = launchId;
|
||||
openForNewBatch();
|
||||
return;
|
||||
}
|
||||
// Reopened from the background task list: skip the progress list when the
|
||||
// batch already has results worth merging.
|
||||
revealMergedViewWhenSettled();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -756,22 +895,38 @@ 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", { completed: progressCompleted, total: batch?.items.length ?? 0 }) : t("multiDbExecute.title") }}
|
||||
{{ mergeViewOpen ? t("multiDbExecute.mergeResults") : executionStarted ? t("multiDbExecute.progress", { completed: progressCompleted, total: batch?.items.length ?? 0 }) : t("multiDbExecute.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="executionStarted && batch" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-5 py-3 text-xs text-muted-foreground tabular-nums">
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-5 py-3 text-xs text-muted-foreground tabular-nums" :data-multi-db-batch-id="batch.id">
|
||||
<span>{{ t("multiDbExecute.progress", { completed: progressCompleted, total: batch.items.length }) }}</span>
|
||||
<Badge variant="secondary">{{ executionModeLabel() }}</Badge>
|
||||
<span class="tabular-nums">{{ t("multiDbExecute.elapsed", { duration: formatDataTransferDuration(elapsedMs) }) }}</span>
|
||||
<span class="tabular-nums" data-multi-db-elapsed>{{ t("multiDbExecute.elapsed", { duration: formatDataTransferDuration(elapsedMs) }) }}</span>
|
||||
<Badge variant="outline">{{ t("multiDbExecute.success") }} {{ progressCounts.success }}</Badge>
|
||||
<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">
|
||||
<MultiSourceMergeView
|
||||
v-if="mergeViewOpen"
|
||||
ref="mergeViewRef"
|
||||
:items="mergeItems"
|
||||
:sql="batch.sql"
|
||||
:duration-ms="elapsedMs"
|
||||
:executed-at="batch.startedAt"
|
||||
:show-export-actions="false"
|
||||
:rerun-disabled="isExecuting"
|
||||
:transactional="mergeBatchIsTransactional"
|
||||
@rerun-target="rerunMergeTarget"
|
||||
@commit-target="(key: string) => settleMergeTargetTransaction(key, 'commit')"
|
||||
@rollback-target="(key: string) => settleMergeTargetTransaction(key, 'rollback')"
|
||||
@commit-all="commitAllMergeTargets"
|
||||
@rollback-all="rollbackAllMergeTargets"
|
||||
/>
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div class="space-y-2">
|
||||
<div v-for="item in batch.items" :key="item.id" class="flex min-w-0 items-start gap-3 rounded-md border px-3 py-2">
|
||||
<Loader2 v-if="item.status === 'running'" class="mt-0.5 h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
@@ -781,7 +936,7 @@ watch(
|
||||
<div v-else class="mt-0.5 h-4 w-4 shrink-0 rounded-full border border-muted-foreground/40" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm" :class="statusClass(item.status)">{{ targetLabel(item.target) }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ statusLabel(item.status) }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ isAwaitingTarget(item) ? t("multiDbExecute.awaitingConfirmation") : statusLabel(item.status) }}</div>
|
||||
<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>
|
||||
@@ -793,12 +948,30 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter class="mx-0 mb-0 shrink-0 border-t px-5 py-3">
|
||||
<Button v-if="isExecuting" variant="destructive" class="gap-1.5" @click="execution.cancel()">
|
||||
<X class="h-4 w-4" />
|
||||
{{ t("multiDbExecute.cancelBatch") }}
|
||||
</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>
|
||||
<template v-if="mergeViewOpen">
|
||||
<Button variant="outline" data-multi-db-merge-back @click="mergeViewOpen = false">{{ t("multiDbExecute.mergeResultsBack") }}</Button>
|
||||
<Button variant="outline" data-multi-db-merge-export-page @click="mergeViewRef?.exportMergedRows('page')">
|
||||
<Download class="h-4 w-4" />
|
||||
{{ t("multiDbExecute.exportCurrentPage") }}
|
||||
</Button>
|
||||
<Button variant="outline" data-multi-db-merge-export-all @click="mergeViewRef?.exportMergedRows('all')">
|
||||
<Download class="h-4 w-4" />
|
||||
{{ t("multiDbExecute.exportAllRows") }}
|
||||
</Button>
|
||||
<Button @click="requestClose">{{ t("common.close") }}</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button v-if="isExecuting" variant="destructive" class="gap-1.5" @click="execution.cancel()">
|
||||
<X class="h-4 w-4" />
|
||||
{{ t("multiDbExecute.cancelBatch") }}
|
||||
</Button>
|
||||
<Button v-if="isExecuting && !batch.context.manualTransaction" variant="outline" @click="requestClose">{{ t("exportProgress.minimize") }}</Button>
|
||||
<Button v-if="canShowMergedResults" variant="outline" class="gap-1.5" data-multi-db-merge @click="mergeViewOpen = true">
|
||||
<Layers class="h-4 w-4" />
|
||||
{{ t("multiDbExecute.mergeResults") }}
|
||||
</Button>
|
||||
<Button v-if="!isExecuting" @click="requestClose">{{ t("common.close") }}</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, Download, RefreshCw, Undo2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { formatSelectionAggregate } from "@/lib/dataGrid/gridSelection";
|
||||
import { formatLocalTimestamp } from "@/lib/backend/debugLog";
|
||||
import { buildXlsxSqlWorksheet } from "@/lib/export/xlsxSqlSheet";
|
||||
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE, MULTI_SOURCE_MERGE_MAX_ROWS, mergeMultiSourceResults } from "@/lib/query/multiSourceResult";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
/**
|
||||
* Quick inspection view for one multi-database execution. When the statement
|
||||
* returns result sets, every target's result is unioned by column name into one
|
||||
* table (first column names the row's source, all-numeric columns get a SUM
|
||||
* footer, rows can be exported as Excel). When it returns no result set — an
|
||||
* UPDATE/DELETE/DDL batch — the view lists one row per target with its affected
|
||||
* row count, status and error, and a failed target can be re-run on its own.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
result?: QueryResult;
|
||||
status?: "pending" | "running" | "pending_commit" | "rolled_back" | "success" | "failed" | "skipped" | "cancelled" | "not_executed";
|
||||
durationMs?: number;
|
||||
errorMessage?: string;
|
||||
/** Present while this target still owns an unsettled transaction session. */
|
||||
transaction?: { canCommit: boolean; settling?: boolean };
|
||||
}>;
|
||||
/** Hidden when the host dialog renders its own export button group. */
|
||||
showExportActions?: boolean;
|
||||
/** Set while the owning batch is still running, so re-running is blocked. */
|
||||
rerunDisabled?: boolean;
|
||||
/** True when the batch ran inside per-target transactions. */
|
||||
transactional?: boolean;
|
||||
/** Statement the batch executed, shown in the header and written into the export. */
|
||||
sql?: string;
|
||||
/** How long the batch took, shown in the header and written into the export. */
|
||||
durationMs?: number;
|
||||
/** When the batch started (epoch ms), shown in the header and exported. */
|
||||
executedAt?: number;
|
||||
}>(),
|
||||
{ showExportActions: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
rerunTarget: [key: string];
|
||||
commitTarget: [key: string];
|
||||
rollbackTarget: [key: string];
|
||||
commitAll: [];
|
||||
rollbackAll: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
/** Rendering cap; the merged statistics always cover every merged row. */
|
||||
const RENDERED_ROW_LIMIT = 1000;
|
||||
|
||||
const merged = computed(() =>
|
||||
mergeMultiSourceResults(
|
||||
props.items.map((item) => ({ key: item.key, label: item.label, result: item.result })),
|
||||
{ sourceColumnLabel: t("multiDbExecute.mergedSourceColumn") },
|
||||
),
|
||||
);
|
||||
/** False for write statements: no target returned columns to union. */
|
||||
const hasTabularResults = computed(() => merged.value.sources.length > 0);
|
||||
const summaryByColumnIndex = computed(() => new Map(merged.value.summaries.map((summary) => [summary.columnIndex, summary])));
|
||||
const renderedRows = computed(() => merged.value.rows.slice(0, RENDERED_ROW_LIMIT));
|
||||
const hiddenRowCount = computed(() => Math.max(0, merged.value.rows.length - renderedRows.value.length));
|
||||
|
||||
/** Sources whose row count reached the per-source fetch cap, so more may exist. */
|
||||
const cappedSourceCount = computed(() => merged.value.sources.filter((source) => source.rowCount >= MULTI_SOURCE_MAX_ROWS_PER_SOURCE).length);
|
||||
const perSourceCap = MULTI_SOURCE_MAX_ROWS_PER_SOURCE;
|
||||
const mergeCap = MULTI_SOURCE_MERGE_MAX_ROWS;
|
||||
|
||||
function statusLabel(status: (typeof props.items)[number]["status"]): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return t("multiDbExecute.running");
|
||||
case "success":
|
||||
return t("multiDbExecute.success");
|
||||
case "pending_commit":
|
||||
return t("multiDbExecute.pendingCommit");
|
||||
case "rolled_back":
|
||||
return t("toolbar.rollback");
|
||||
case "failed":
|
||||
return t("multiDbExecute.failed");
|
||||
case "skipped":
|
||||
return t("multiDbExecute.skipped");
|
||||
case "cancelled":
|
||||
return t("multiDbExecute.cancelled");
|
||||
case "not_executed":
|
||||
return t("multiDbExecute.notExecuted");
|
||||
default:
|
||||
return t("multiDbExecute.pending");
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status: (typeof props.items)[number]["status"]): string {
|
||||
if (status === "success") return "text-emerald-600 dark:text-emerald-300";
|
||||
if (status === "pending_commit") return "text-amber-600 dark:text-amber-300";
|
||||
if (status === "failed") return "text-destructive";
|
||||
if (status === "running") return "text-primary";
|
||||
if (status === "skipped" || status === "cancelled" || status === "not_executed" || status === "rolled_back") return "text-amber-600 dark:text-amber-300";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
/** A statement that ran, whether or not its transaction is still open. */
|
||||
function hasWrittenRows(item: (typeof props.items)[number]): boolean {
|
||||
return item.status === "success" || item.status === "pending_commit";
|
||||
}
|
||||
|
||||
/** Affected rows are only meaningful for a statement that actually ran. The
|
||||
* column already names them, so the cell carries the bare count. */
|
||||
function affectedRowsText(item: (typeof props.items)[number]): string {
|
||||
if (!hasWrittenRows(item)) return "—";
|
||||
return String(item.result?.affected_rows ?? 0);
|
||||
}
|
||||
|
||||
/** Rows the batch wrote in total, summarized in the header for write statements. */
|
||||
const totalAffectedRows = computed(() => props.items.reduce((sum, item) => sum + (hasWrittenRows(item) ? (item.result?.affected_rows ?? 0) : 0), 0));
|
||||
|
||||
function canRerun(item: (typeof props.items)[number]): boolean {
|
||||
// A target that still owns a session has to be settled before it is re-run.
|
||||
if (item.transaction) return false;
|
||||
return item.status !== undefined && !hasWrittenRows(item) && item.status !== "running" && item.status !== "pending";
|
||||
}
|
||||
|
||||
/** Targets whose changes are still pending in their own transaction. */
|
||||
const openTransactionCount = computed(() => props.items.filter((item) => item.transaction !== undefined).length);
|
||||
/** Committing skips the targets that errored: they have nothing to write. */
|
||||
const committableCount = computed(() => props.items.filter((item) => item.transaction?.canCommit === true).length);
|
||||
|
||||
function isTransactionOpen(item: (typeof props.items)[number]): boolean {
|
||||
return props.transactional === true && item.transaction !== undefined;
|
||||
}
|
||||
|
||||
function txnStateLabel(item: (typeof props.items)[number]): string {
|
||||
if (item.transaction) return t("multiDbExecute.txnOpen");
|
||||
if (item.status === "rolled_back") return t("multiDbExecute.txnRolledBack");
|
||||
if (hasWrittenRows(item)) return t("multiDbExecute.txnCommitted");
|
||||
return "—";
|
||||
}
|
||||
|
||||
function durationText(durationMs: number | undefined): string {
|
||||
if (durationMs === undefined) return "";
|
||||
return durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)} s` : `${Math.round(durationMs)} ms`;
|
||||
}
|
||||
|
||||
/** Local execution timestamp, sharing the app's timezone-explicit format. */
|
||||
function timestampText(executedAt: number | undefined): string {
|
||||
if (executedAt === undefined) return "";
|
||||
return formatLocalTimestamp(new Date(executedAt)).replace("T", " ").replace(/\.\d+/, "");
|
||||
}
|
||||
|
||||
/** Rows of the write-statement export: one line per target, no result set. */
|
||||
function writeStatementExport(): { columns: string[]; rows: (string | number)[][] } {
|
||||
const columns = [t("multiDbExecute.mergedSourceColumn"), t("multiDbExecute.statusColumn")];
|
||||
if (props.transactional) columns.push(t("multiDbExecute.txnColumn"));
|
||||
columns.push(t("multiDbExecute.affectedRowsColumn"), t("multiDbExecute.durationColumn"), t("multiDbExecute.errorColumn"));
|
||||
const rows = props.items.map((item) => {
|
||||
const row: (string | number)[] = [item.label, statusLabel(item.status)];
|
||||
if (props.transactional) row.push(txnStateLabel(item));
|
||||
row.push(hasWrittenRows(item) ? (item.result?.affected_rows ?? 0) : "", durationText(item.durationMs), item.errorMessage ?? "");
|
||||
return row;
|
||||
});
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
function cellText(value: string | number | boolean | null): string {
|
||||
return value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function summaryCellText(columnIndex: number): string {
|
||||
const summary = summaryByColumnIndex.value.get(columnIndex);
|
||||
return summary ? formatSelectionAggregate(summary.sum) : "";
|
||||
}
|
||||
|
||||
function summaryCellTitle(columnIndex: number): string | undefined {
|
||||
const summary = summaryByColumnIndex.value.get(columnIndex);
|
||||
return summary ? t("multiDbExecute.mergedSummaryCellHint", { count: summary.count }) : undefined;
|
||||
}
|
||||
|
||||
function exportFileName(scope: "page" | "all"): string {
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
return ["dbx-merged-result", scope === "page" ? "page" : "all", timestamp].join("_") + ".xlsx";
|
||||
}
|
||||
|
||||
/** SQL + timing worksheet: mirrors the grid export's "include SQL sheet" pattern. */
|
||||
function buildSqlWorksheet() {
|
||||
const entries: Array<{ resultName: string; sql: string }> = [];
|
||||
const executedAt = timestampText(props.executedAt);
|
||||
if (executedAt) entries.push({ resultName: t("multiDbExecute.executedAtColumn"), sql: executedAt });
|
||||
const duration = durationText(props.durationMs);
|
||||
if (duration) entries.push({ resultName: t("multiDbExecute.durationColumn"), sql: duration });
|
||||
if (props.sql?.trim()) entries.push({ resultName: "SQL", sql: props.sql });
|
||||
return buildXlsxSqlWorksheet(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one Excel workbook for the merged view.
|
||||
*
|
||||
* A statement with a result set exports the merged table (`page` = the rows on
|
||||
* screen, `all` = every merged row). A write statement without a result set
|
||||
* exports the per-target list instead —?status, transaction state, affected
|
||||
* rows, elapsed time and error. Both get a second "SQL" sheet carrying the
|
||||
* executed statement and the batch duration.
|
||||
*
|
||||
* The web build assembles the workbook in the browser and downloads it, the
|
||||
* desktop build asks for a path and writes it through the backend.
|
||||
*/
|
||||
async function exportMergedRows(scope: "page" | "all"): Promise<void> {
|
||||
const tabular = hasTabularResults.value;
|
||||
const rows = scope === "page" ? renderedRows.value : merged.value.rows;
|
||||
if (tabular && rows.length === 0) {
|
||||
toast(t("multiDbExecute.mergedEmpty"), 3000);
|
||||
return;
|
||||
}
|
||||
if (!tabular && props.items.length === 0) {
|
||||
toast(t("multiDbExecute.mergedEmpty"), 3000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let outputPath = exportFileName(scope);
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ defaultPath: outputPath, filters: [{ name: "Excel", extensions: ["xlsx"] }] });
|
||||
if (!path) return;
|
||||
outputPath = String(path);
|
||||
}
|
||||
const writeExport = tabular ? undefined : writeStatementExport();
|
||||
const dataSheet = {
|
||||
sheetName: t("multiDbExecute.mergedSheetName"),
|
||||
columns: tabular ? [...merged.value.columns] : (writeExport?.columns ?? []),
|
||||
columnTypes: (tabular ? merged.value.columns : (writeExport?.columns ?? [])).map(() => ""),
|
||||
rows: tabular ? rows : (writeExport?.rows ?? []),
|
||||
numericColumnRightAlign: true,
|
||||
autoFilter: true,
|
||||
};
|
||||
const sqlWorksheet = buildSqlWorksheet();
|
||||
if (sqlWorksheet) {
|
||||
await api.exportQueryResultsXlsx(outputPath, [dataSheet, sqlWorksheet], true);
|
||||
} else {
|
||||
await api.exportQueryResultXlsx(outputPath, dataSheet.sheetName, dataSheet.columns, dataSheet.columnTypes, undefined, dataSheet.rows, true, true);
|
||||
}
|
||||
const exportedRows = tabular ? rows.length : props.items.length;
|
||||
toast(cappedSourceCount.value > 0 && tabular ? t("multiDbExecute.exportedRowsCapped", { rows: exportedRows, count: cappedSourceCount.value, limit: perSourceCap }) : t("multiDbExecute.exportedRows", { rows: exportedRows }), 4000);
|
||||
} catch (error) {
|
||||
toast(t("grid.exportFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ exportMergedRows });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col" data-multi-source-merge-view>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-5 py-2 text-xs text-muted-foreground">
|
||||
<!-- A write batch has no rows to merge: report what it touched instead of
|
||||
the merged-source statistics, which would all read zero. -->
|
||||
<span v-if="hasTabularResults">{{ t("multiDbExecute.mergedSources", { count: merged.sources.length, rows: merged.rowCount }) }}</span>
|
||||
<span v-if="hasTabularResults">{{ t("multiDbExecute.mergedSourceRowCap", { count: perSourceCap }) }}</span>
|
||||
<span v-else data-merge-write-summary>{{ t("multiDbExecute.mergedWriteSummary", { targets: items.length, rows: totalAffectedRows }) }}</span>
|
||||
<span v-if="hasTabularResults && cappedSourceCount > 0" class="font-medium text-amber-600 dark:text-amber-300" data-merge-source-cap-reached>
|
||||
{{ t("multiDbExecute.mergedSourceCapReached", { count: cappedSourceCount, limit: perSourceCap }) }}
|
||||
</span>
|
||||
<span v-if="hasTabularResults && merged.truncated" class="font-medium text-amber-600 dark:text-amber-300" data-merge-truncated>
|
||||
{{ t("multiDbExecute.mergedTruncated", { count: mergeCap }) }}
|
||||
</span>
|
||||
<span v-if="hasTabularResults && hiddenRowCount > 0">{{ t("multiDbExecute.mergedRowLimit", { count: renderedRows.length, rows: merged.rowCount }) }}</span>
|
||||
<span v-if="executedAt !== undefined" class="tabular-nums" data-merge-executed-at>{{ t("multiDbExecute.executedAtChip", { time: timestampText(executedAt) }) }}</span>
|
||||
<span v-if="durationMs !== undefined" class="tabular-nums" data-merge-duration>{{ t("multiDbExecute.elapsedColumn", { duration: durationText(durationMs) }) }}</span>
|
||||
<span v-if="sql" class="min-w-0 max-w-[420px] truncate font-mono" :title="sql" data-merge-sql>{{ sql }}</span>
|
||||
<span v-if="transactional && openTransactionCount > 0" class="font-medium text-amber-600 dark:text-amber-300" data-merge-txn-open>
|
||||
{{ t("multiDbExecute.txnOpenHint", { count: openTransactionCount }) }}
|
||||
</span>
|
||||
<span v-if="showExportActions || transactional" class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<template v-if="transactional">
|
||||
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-commit-all :disabled="committableCount === 0 || rerunDisabled" @click="emit('commitAll')">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
{{ t("multiDbExecute.commitAllTargets", { count: committableCount }) }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-rollback-all :disabled="openTransactionCount === 0 || rerunDisabled" @click="emit('rollbackAll')">
|
||||
<Undo2 class="h-3.5 w-3.5" />
|
||||
{{ t("multiDbExecute.rollbackAllTargets", { count: openTransactionCount }) }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-if="showExportActions">
|
||||
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-export-page @click="exportMergedRows('page')">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{{ t("multiDbExecute.exportCurrentPage") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-export-all @click="exportMergedRows('all')">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{{ t("multiDbExecute.exportAllRows") }}
|
||||
</Button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<!-- Write statements (UPDATE / DELETE / DDL) return no result set: report
|
||||
one row per target instead of an empty merged table. -->
|
||||
<table v-if="!hasTabularResults" class="w-full border-collapse text-xs" data-merge-target-results>
|
||||
<thead class="sticky top-0 z-10 bg-muted text-left">
|
||||
<tr>
|
||||
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.mergedSourceColumn") }}</th>
|
||||
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.statusColumn") }}</th>
|
||||
<th v-if="transactional" class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.txnColumn") }}</th>
|
||||
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.affectedRowsColumn") }}</th>
|
||||
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.durationColumn") }}</th>
|
||||
<th class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ t("multiDbExecute.errorColumn") }}</th>
|
||||
<th class="border-b px-2 py-1" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.key" class="border-b align-top hover:bg-muted/40">
|
||||
<td class="max-w-[280px] truncate border-r px-2 py-1 whitespace-nowrap" :title="item.label">{{ item.label }}</td>
|
||||
<td class="border-r px-2 py-1 whitespace-nowrap" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</td>
|
||||
<td v-if="transactional" class="border-r px-2 py-1 whitespace-nowrap" :class="{ 'text-amber-600 dark:text-amber-300': item.transaction !== undefined }" data-merge-txn-state>
|
||||
{{ txnStateLabel(item) }}
|
||||
</td>
|
||||
<td class="border-r px-2 py-1 text-right tabular-nums whitespace-nowrap">{{ affectedRowsText(item) }}</td>
|
||||
<td class="border-r px-2 py-1 text-right tabular-nums whitespace-nowrap">{{ durationText(item.durationMs) }}</td>
|
||||
<td class="max-w-[360px] border-r px-2 py-1 text-destructive">
|
||||
<span class="line-clamp-3 break-words whitespace-pre-wrap">{{ item.errorMessage ?? "" }}</span>
|
||||
</td>
|
||||
<td class="px-2 py-1 text-right whitespace-nowrap">
|
||||
<template v-if="isTransactionOpen(item) && item.transaction">
|
||||
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-commit :disabled="rerunDisabled || item.transaction.settling === true || !item.transaction.canCommit" @click="emit('commitTarget', item.key)">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
{{ t("toolbar.commit") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="ml-1 h-6 gap-1 px-2 text-xs" data-merge-rollback :disabled="rerunDisabled || item.transaction.settling === true" @click="emit('rollbackTarget', item.key)">
|
||||
<Undo2 class="h-3.5 w-3.5" />
|
||||
{{ t("toolbar.rollback") }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-else-if="canRerun(item)" variant="outline" size="sm" class="h-6 gap-1 px-2 text-xs" data-merge-rerun :disabled="rerunDisabled" @click="emit('rerunTarget', item.key)">
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{{ t("multiDbExecute.rerunTarget") }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td :colspan="transactional ? 7 : 6" class="px-2 py-6 text-center text-muted-foreground">{{ t("multiDbExecute.mergedEmpty") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table v-else class="w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-muted text-left">
|
||||
<tr>
|
||||
<th v-for="(column, columnIndex) in merged.columns" :key="`head-${columnIndex}`" class="border-r border-b px-2 py-1 font-medium whitespace-nowrap">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in renderedRows" :key="`row-${rowIndex}`" class="border-b hover:bg-muted/40">
|
||||
<td v-for="(cell, columnIndex) in row" :key="`cell-${rowIndex}-${columnIndex}`" class="max-w-[320px] truncate border-r px-2 py-1 whitespace-nowrap" :class="{ 'text-muted-foreground': cell === null }" :title="cellText(cell)">
|
||||
{{ cellText(cell) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="renderedRows.length === 0">
|
||||
<td :colspan="merged.columns.length" class="px-2 py-6 text-center text-muted-foreground">{{ t("multiDbExecute.mergedEmpty") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot v-if="merged.summaries.length > 0" class="sticky bottom-0 z-10 bg-muted">
|
||||
<tr>
|
||||
<td class="border-r border-t px-2 py-1 text-center font-medium text-muted-foreground">{{ t("multiDbExecute.mergedSummaryLabel") }}</td>
|
||||
<td v-for="columnIndex in merged.columns.length - 1" :key="`sum-${columnIndex}`" class="border-r border-t px-2 py-1 text-right tabular-nums" :title="summaryCellTitle(columnIndex)">
|
||||
{{ summaryCellText(columnIndex) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { computed, createApp, h, nextTick, ref, type App, type Ref } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue";
|
||||
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
|
||||
import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
exportQueryResultsXlsx: vi.fn().mockResolvedValue(undefined),
|
||||
exportQueryResultXlsx: vi.fn().mockResolvedValue(undefined),
|
||||
registerTaskCancelHandler: vi.fn(),
|
||||
unregisterTaskCancelHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
// Parameters are rendered so a call that forgot them is visible in the DOM.
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (params ? `${key}(${Object.values(params).join("/")})` : key) }),
|
||||
}));
|
||||
// Web runtime: exports skip the save dialog and manual transactions are unavailable.
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
exportQueryResultsXlsx: mocks.exportQueryResultsXlsx,
|
||||
exportQueryResultXlsx: mocks.exportQueryResultXlsx,
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/stores/sqlExecutionTargetGroupStore", () => ({ useSqlExecutionTargetGroupStore: () => ({ getGroupsByDatabaseType: () => [], getGroup: () => undefined }) }));
|
||||
vi.mock("@/composables/useExportTracker", () => ({
|
||||
formatDataTransferDuration: String,
|
||||
useExportTracker: () => ({ addMultiDbExecutionTask: vi.fn(), updateMultiDbExecutionTask: vi.fn(), registerTaskCancelHandler: mocks.registerTaskCancelHandler, unregisterTaskCancelHandler: mocks.unregisterTaskCancelHandler }),
|
||||
}));
|
||||
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" })),
|
||||
}),
|
||||
}));
|
||||
|
||||
const SQL = "SELECT id, amount FROM t";
|
||||
const TWO_TARGETS = [
|
||||
{ connectionId: "test", database: "db-a" },
|
||||
{ connectionId: "test", database: "db-b" },
|
||||
];
|
||||
|
||||
function table(rows: (string | number)[][]): QueryResult {
|
||||
return { columns: ["id", "amount"], rows, affected_rows: rows.length, execution_time_ms: 1 };
|
||||
}
|
||||
|
||||
function write(affectedRows: number): QueryResult {
|
||||
return { columns: [], rows: [], affected_rows: affectedRows, execution_time_ms: 1 };
|
||||
}
|
||||
|
||||
function resultFor(database: string): QueryResult {
|
||||
return database === "db-a" ? table([[1, 10]]) : table([[2, 20]]);
|
||||
}
|
||||
|
||||
let app: App | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
document.body.innerHTML = "";
|
||||
mocks.toast.mockClear();
|
||||
mocks.exportQueryResultsXlsx.mockClear();
|
||||
mocks.registerTaskCancelHandler.mockClear();
|
||||
mocks.unregisterTaskCancelHandler.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const found = [...document.querySelectorAll<HTMLButtonElement>("button")].find((candidate) => candidate.textContent?.trim() === label);
|
||||
if (!found) throw new Error(`Missing button ${label}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Mounts with a reactive `open` so minimizing and reopening can be observed. */
|
||||
interface DialogOptions {
|
||||
open: Ref<boolean>;
|
||||
targets?: Array<{ connectionId: string; database: string }>;
|
||||
launchId?: Ref<number>;
|
||||
cancelTarget?: MultiDbExecutionAdapter["cancelTarget"];
|
||||
cancelPending?: MultiDbExecutionAdapter["cancelPending"];
|
||||
}
|
||||
|
||||
function mountDialog(executeTarget: MultiDbExecutionAdapter["executeTarget"], options: DialogOptions) {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp({
|
||||
render: () =>
|
||||
h(MultiDbExecuteDialog, {
|
||||
open: options.open.value,
|
||||
"onUpdate:open": (value: boolean) => (options.open.value = value),
|
||||
sql: SQL,
|
||||
sourceTabId: "source",
|
||||
databaseType: "oceanbase-oracle",
|
||||
initialTargets: options.targets ?? TWO_TARGETS,
|
||||
launchId: options.launchId?.value ?? 1,
|
||||
executeTarget,
|
||||
cancelTarget: options.cancelTarget,
|
||||
cancelPending: options.cancelPending,
|
||||
}),
|
||||
});
|
||||
app.use(createPinia());
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
async function executeBatch(executeTarget: MultiDbExecutionAdapter["executeTarget"], options: DialogOptions) {
|
||||
mountDialog(executeTarget, options);
|
||||
await flushPromises();
|
||||
button("multiDbExecute.execute").click();
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
describe("multi-database merged view reveal paths", () => {
|
||||
it("unions every source and labels the rows, the statement and the batch timings", async () => {
|
||||
const open = ref(true);
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
|
||||
await executeBatch(executeTarget, { open });
|
||||
|
||||
button("multiDbExecute.mergeResults").click();
|
||||
await nextTick();
|
||||
|
||||
const view = document.querySelector("[data-multi-source-merge-view]");
|
||||
expect(view).not.toBeNull();
|
||||
const rows = [...(view?.querySelectorAll("tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim()));
|
||||
expect(rows).toEqual([
|
||||
["Test / db-a", "1", "10"],
|
||||
["Test / db-b", "2", "20"],
|
||||
]);
|
||||
|
||||
// The statement, the elapsed time and the start time come from the batch.
|
||||
expect(document.querySelector("[data-merge-sql]")?.textContent?.trim()).toBe(SQL);
|
||||
expect(document.querySelector("[data-merge-duration]")?.textContent?.trim()).not.toBe("");
|
||||
expect(document.querySelector("[data-merge-executed-at]")?.textContent?.trim()).not.toBe("");
|
||||
});
|
||||
|
||||
it("names the merged view in the title instead of the bar's parametrized progress", async () => {
|
||||
const open = ref(true);
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
|
||||
await executeBatch(executeTarget, { open });
|
||||
|
||||
const title = () => document.querySelector('[data-slot="dialog-title"]')?.textContent?.trim() ?? "";
|
||||
expect(title()).toContain("multiDbExecute.progress(");
|
||||
|
||||
button("multiDbExecute.mergeResults").click();
|
||||
await nextTick();
|
||||
|
||||
expect(title()).toBe("multiDbExecute.mergeResults");
|
||||
expect(document.body.textContent).toContain("multiDbExecute.progress(2/2)");
|
||||
});
|
||||
|
||||
it("exports the merged rows and the statement through the dialog footer", async () => {
|
||||
const open = ref(true);
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
|
||||
await executeBatch(executeTarget, { open });
|
||||
|
||||
button("multiDbExecute.mergeResults").click();
|
||||
await nextTick();
|
||||
(document.querySelector("[data-multi-db-merge-export-all]") as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
|
||||
|
||||
const sheets = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Array<{ sheetName: string; rows: unknown[][] }>])[1];
|
||||
expect(sheets[0]?.rows).toHaveLength(2);
|
||||
expect(sheets[1]?.rows.flat().join("\n")).toContain(SQL);
|
||||
});
|
||||
|
||||
it("re-runs only the failed target from the per-target list", async () => {
|
||||
const open = ref(true);
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => (input.target.database === "db-a" ? { status: "success" as const, durationMs: 4, result: write(3) } : { status: "failed" as const, durationMs: 2, errorMessage: "boom" }));
|
||||
await executeBatch(executeTarget, { open });
|
||||
|
||||
button("multiDbExecute.mergeResults").click();
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("boom");
|
||||
|
||||
executeTarget.mockImplementation(async (input) => (input.target.database === "db-a" ? { status: "success" as const, durationMs: 4, result: write(3) } : { status: "success" as const, durationMs: 6, result: write(1) }));
|
||||
(document.querySelector("[data-merge-rerun]") as HTMLButtonElement).click();
|
||||
await flushPromises();
|
||||
|
||||
// The adapter ran for the retried target only, and the row shows the retry's own outcome.
|
||||
expect(executeTarget).toHaveBeenCalledTimes(3);
|
||||
expect(executeTarget.mock.calls[2]?.[0].target.database).toBe("db-b");
|
||||
expect(document.querySelector("[data-merge-rerun]")).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("boom");
|
||||
});
|
||||
|
||||
it("keeps deferred retries cancellable across reopen and rolls back their late transactions on unmount", async () => {
|
||||
const open = ref(true);
|
||||
const launchId = ref(1);
|
||||
const cancelTarget = vi.fn().mockResolvedValue(undefined);
|
||||
const cancelPending = vi.fn();
|
||||
const finish = vi.fn().mockResolvedValue(undefined);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>().mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||
await executeBatch(executeTarget, { open, launchId, cancelTarget, cancelPending });
|
||||
const batchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
|
||||
button("multiDbExecute.mergeResults").click();
|
||||
await nextTick();
|
||||
mocks.registerTaskCancelHandler.mockClear();
|
||||
executeTarget.mockImplementationOnce(async () => {
|
||||
await gate;
|
||||
return { status: "pending_commit", transaction: { canCommit: true, finish } };
|
||||
});
|
||||
(document.querySelector("[data-merge-rerun]") as HTMLButtonElement).click();
|
||||
await flushPromises();
|
||||
|
||||
expect(document.querySelector<HTMLButtonElement>("[data-merge-rerun]")?.disabled).toBe(true);
|
||||
expect(mocks.registerTaskCancelHandler).toHaveBeenLastCalledWith(batchId, expect.any(Function));
|
||||
const cancel = mocks.registerTaskCancelHandler.mock.calls.at(-1)![1] as () => Promise<void>;
|
||||
open.value = false;
|
||||
await flushPromises();
|
||||
launchId.value += 1;
|
||||
open.value = true;
|
||||
await flushPromises();
|
||||
expect(document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id")).toBe(batchId);
|
||||
expect(executeTarget).toHaveBeenCalledTimes(3);
|
||||
await cancel();
|
||||
expect(cancelTarget).toHaveBeenCalledExactlyOnceWith("source", batchId);
|
||||
expect(cancelPending).toHaveBeenCalledExactlyOnceWith(batchId);
|
||||
expect(executeTarget.mock.calls[2]![0].isCancellationRequested()).toBe(true);
|
||||
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
release();
|
||||
await vi.waitFor(() => expect(finish).toHaveBeenCalledExactlyOnceWith("rollback"));
|
||||
});
|
||||
|
||||
it.each([0, 5000])("resets confirmation timing for a new batch with %i ms wait but preserves same-batch reopen", async (secondWaitMs) => {
|
||||
let now = 1000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const open = ref(true);
|
||||
const launchId = ref(1);
|
||||
let release!: () => void;
|
||||
let gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => {
|
||||
if (input.target.database === "db-a") await gate;
|
||||
return { status: "success", result: resultFor(input.target.database) };
|
||||
});
|
||||
await executeBatch(executeTarget, { open, launchId });
|
||||
const firstBatchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
|
||||
const dangerStore = useSqlExecutionDangerStore();
|
||||
dangerStore.pending = { sql: SQL, kind: "sql", scopeId: firstBatchId ?? undefined, targetLabel: "Test / db-a" };
|
||||
await nextTick();
|
||||
now = 11_000;
|
||||
dangerStore.pending = undefined;
|
||||
await nextTick();
|
||||
now = 11_100;
|
||||
release();
|
||||
await flushPromises();
|
||||
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(100)");
|
||||
|
||||
open.value = false;
|
||||
await flushPromises();
|
||||
open.value = true;
|
||||
await flushPromises();
|
||||
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(100)");
|
||||
expect(executeTarget).toHaveBeenCalledTimes(2);
|
||||
|
||||
now = 20_000;
|
||||
gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
launchId.value += 1;
|
||||
await flushPromises();
|
||||
button("multiDbExecute.execute").click();
|
||||
await flushPromises();
|
||||
const secondBatchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
|
||||
expect(secondBatchId).not.toBe(firstBatchId);
|
||||
if (secondWaitMs > 0) {
|
||||
dangerStore.pending = { sql: SQL, kind: "sql", scopeId: secondBatchId ?? undefined, targetLabel: "Test / db-a" };
|
||||
await nextTick();
|
||||
now += secondWaitMs;
|
||||
dangerStore.pending = undefined;
|
||||
await nextTick();
|
||||
}
|
||||
now += 250;
|
||||
release();
|
||||
await flushPromises();
|
||||
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe("multiDbExecute.elapsed(250)");
|
||||
button("multiDbExecute.mergeResults").click();
|
||||
await nextTick();
|
||||
button("multiDbExecute.exportAllRows").click();
|
||||
await flushPromises();
|
||||
const sheets = mocks.exportQueryResultsXlsx.mock.calls[0]?.[1] as Array<{ sheetName: string; rows: string[][] }>;
|
||||
expect(
|
||||
sheets
|
||||
.find((sheet) => sheet.sheetName === "SQL")
|
||||
?.rows.flat()
|
||||
.join("\n"),
|
||||
).toContain("250 ms");
|
||||
});
|
||||
|
||||
it("keeps merging out of a single-target batch", async () => {
|
||||
const open = ref(true);
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
|
||||
await executeBatch(executeTarget, { open, targets: [{ connectionId: "test", database: "db-a" }] });
|
||||
|
||||
expect(document.querySelector("[data-multi-db-merge]")).toBeNull();
|
||||
});
|
||||
|
||||
it("parks the clock and labels the target while its confirmation is on screen", async () => {
|
||||
const open = ref(true);
|
||||
const releases: Array<() => void> = [];
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => {
|
||||
await new Promise<void>((resolve) => releases.push(resolve));
|
||||
return { status: "success" as const, durationMs: 3, result: resultFor(input.target.database) };
|
||||
});
|
||||
await executeBatch(executeTarget, { open });
|
||||
|
||||
const batchId = document.querySelector("[data-multi-db-batch-id]")?.getAttribute("data-multi-db-batch-id");
|
||||
expect(batchId).toBeTruthy();
|
||||
// The prompt the batch is waiting for, as the danger store publishes it.
|
||||
useSqlExecutionDangerStore().pending = { sql: SQL, kind: "sql", scopeId: batchId ?? undefined, targetLabel: "Test / db-a" };
|
||||
await nextTick();
|
||||
|
||||
// The parked target is waiting for the operator, not running.
|
||||
expect(document.body.textContent).toContain("multiDbExecute.awaitingConfirmation");
|
||||
const elapsedBefore = document.querySelector("[data-multi-db-elapsed]")?.textContent;
|
||||
expect(elapsedBefore).toBeTruthy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
await nextTick();
|
||||
expect(document.querySelector("[data-multi-db-elapsed]")?.textContent).toBe(elapsedBefore);
|
||||
|
||||
releases.splice(0).forEach((release) => release());
|
||||
});
|
||||
|
||||
it("announces a batch that finished while minimized with an action into the merged view", async () => {
|
||||
const open = ref(true);
|
||||
const releases: Array<() => void> = [];
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => {
|
||||
await new Promise<void>((resolve) => releases.push(resolve));
|
||||
return { status: "success" as const, durationMs: 3, result: resultFor(input.target.database) };
|
||||
});
|
||||
await executeBatch(executeTarget, { open });
|
||||
|
||||
// Minimize while the targets are still running, then let them finish.
|
||||
open.value = false;
|
||||
await nextTick();
|
||||
for (let round = 0; round < 4; round += 1) {
|
||||
releases.splice(0).forEach((release) => release());
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
const announcement = mocks.toast.mock.calls.at(-1) as [string, number, { label: string; onClick: () => void }];
|
||||
expect(announcement[0]).toBe("multiDbExecute.backgroundBatchDone(2)");
|
||||
expect(announcement[1]).toBe(8000);
|
||||
expect(announcement[2].label).toBe("multiDbExecute.mergeResults");
|
||||
|
||||
announcement[2].onClick();
|
||||
await nextTick();
|
||||
expect(open.value).toBe(true);
|
||||
expect(document.querySelector("[data-multi-source-merge-view]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reopens a settled batch straight in the merged view without re-running it", async () => {
|
||||
const open = ref(true);
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async (input) => ({ status: "success" as const, durationMs: 3, result: resultFor(input.target.database) }));
|
||||
await executeBatch(executeTarget, { open });
|
||||
|
||||
button("common.close").click();
|
||||
await flushPromises();
|
||||
expect(open.value).toBe(false);
|
||||
|
||||
open.value = true;
|
||||
await flushPromises();
|
||||
|
||||
expect(document.querySelector("[data-multi-source-merge-view]")).not.toBeNull();
|
||||
expect(executeTarget).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { computed, createApp, nextTick, type App } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue";
|
||||
import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
|
||||
|
||||
@@ -37,6 +38,7 @@ 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.use(createPinia());
|
||||
app.mount(root);
|
||||
await flushPromises();
|
||||
const checkbox = document.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exportQueryResultsXlsx: vi.fn().mockResolvedValue(undefined),
|
||||
exportQueryResultXlsx: vi.fn().mockResolvedValue(undefined),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
exportQueryResultsXlsx: mocks.exportQueryResultsXlsx,
|
||||
exportQueryResultXlsx: mocks.exportQueryResultXlsx,
|
||||
}));
|
||||
// The web build assembles the workbook in the browser; the desktop build would
|
||||
// first ask for a path, which is covered by the shared export code instead.
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
|
||||
interface Sheet {
|
||||
sheetName: string;
|
||||
columns: string[];
|
||||
rows: Array<Array<string | number>>;
|
||||
}
|
||||
|
||||
function result(columns: string[], rows: (string | number | null)[][], affectedRows = rows.length): QueryResult {
|
||||
return { columns, rows, affected_rows: affectedRows, execution_time_ms: 1 };
|
||||
}
|
||||
|
||||
function mountView(props: Record<string, unknown>) {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp({ render: () => h(MultiSourceMergeView, props) });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
return { host, app };
|
||||
}
|
||||
|
||||
let mounted: { host: HTMLElement; app: App } | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.exportQueryResultsXlsx.mockClear();
|
||||
mocks.exportQueryResultXlsx.mockClear();
|
||||
mocks.toast.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.app.unmount();
|
||||
mounted?.host.remove();
|
||||
mounted = undefined;
|
||||
});
|
||||
|
||||
function exportButton(selector: string): HTMLButtonElement {
|
||||
const button = mounted?.host.querySelector<HTMLButtonElement>(selector);
|
||||
if (!button) throw new Error(`Missing export button ${selector}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
describe("merged view export", () => {
|
||||
it.each(["page", "all"])("omits hidden middle columns from the display, summaries and %s export", async (scope) => {
|
||||
mounted = mountView({
|
||||
items: [
|
||||
{ key: "a", label: "conn-a", status: "success", result: { ...result(["name", "__DBX_PK_0", "amount"], [["Alice", 987654, 10]]), hidden_column_indexes: [1] } },
|
||||
{ key: "b", label: "conn-b", status: "success", result: result(["amount", "name"], [[20, "Bob"]]) },
|
||||
],
|
||||
sql: "SELECT name, amount FROM t",
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect([...mounted.host.querySelectorAll("thead th")].map((cell) => cell.textContent?.trim())).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "name", "amount"]);
|
||||
expect([...mounted.host.querySelectorAll("tbody tr")].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim()))).toEqual([
|
||||
["conn-a", "Alice", "10"],
|
||||
["conn-b", "Bob", "20"],
|
||||
]);
|
||||
expect(mounted.host.querySelector("tfoot")?.textContent).toContain("30");
|
||||
expect(mounted.host.textContent).not.toContain("__DBX_PK_0");
|
||||
expect(mounted.host.textContent).not.toContain("987654");
|
||||
|
||||
exportButton(`[data-merge-export-${scope}]`).click();
|
||||
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
|
||||
const sheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0];
|
||||
expect(sheet?.columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "name", "amount"]);
|
||||
expect(sheet?.rows).toEqual([
|
||||
["conn-a", "Alice", 10],
|
||||
["conn-b", "Bob", 20],
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports every merged row plus a SQL sheet carrying the statement, the start time and the duration", async () => {
|
||||
mounted = mountView({
|
||||
items: [
|
||||
{ key: "a", label: "conn-a", status: "success", result: result(["id", "amount"], [[1, 10]]) },
|
||||
{ key: "b", label: "conn-b", status: "success", result: result(["id", "amount"], [[2, 20]]) },
|
||||
],
|
||||
sql: "SELECT id, amount FROM t",
|
||||
durationMs: 1234,
|
||||
executedAt: Date.UTC(2026, 0, 2, 3, 4, 5),
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
exportButton("[data-merge-export-all]").click();
|
||||
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
|
||||
|
||||
const [outputPath, sheets, ...rest] = mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[], boolean];
|
||||
expect(outputPath).toMatch(/^dbx-merged-result_all_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.xlsx$/);
|
||||
expect(rest).toEqual([true]);
|
||||
|
||||
const [dataSheet, sqlSheet] = sheets;
|
||||
expect(dataSheet?.columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "id", "amount"]);
|
||||
expect(dataSheet?.rows).toEqual([
|
||||
["conn-a", 1, 10],
|
||||
["conn-b", 2, 20],
|
||||
]);
|
||||
// The statement and the batch timings travel in their own sheet.
|
||||
expect(sqlSheet?.sheetName).toBe("SQL");
|
||||
const sqlCells = (sqlSheet?.rows ?? []).flat().join("\n");
|
||||
expect(sqlCells).toContain("SELECT id, amount FROM t");
|
||||
expect(sqlCells).toContain(i18n.global.t("multiDbExecute.durationColumn"));
|
||||
expect(sqlCells).toContain("1.2 s");
|
||||
expect(String(sqlCells)).toMatch(/20\d\d-\d\d-\d\d/);
|
||||
});
|
||||
|
||||
it("exports only the rows on screen for the current page", async () => {
|
||||
const rows = Array.from({ length: 1200 }, (_, index) => [index, index * 2]);
|
||||
mounted = mountView({
|
||||
items: [{ key: "a", label: "conn-a", status: "success", result: result(["id", "amount"], rows) }],
|
||||
sql: "SELECT id, amount FROM big",
|
||||
durationMs: 10,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
exportButton("[data-merge-export-page]").click();
|
||||
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
|
||||
const pageSheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0];
|
||||
expect(pageSheet?.rows).toHaveLength(1000);
|
||||
|
||||
exportButton("[data-merge-export-all]").click();
|
||||
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(2));
|
||||
const allSheet = (mocks.exportQueryResultsXlsx.mock.calls[1] as [string, Sheet[]])[1][0];
|
||||
expect(allSheet?.rows).toHaveLength(1200);
|
||||
});
|
||||
|
||||
it("exports the per-target list when the batch returned no result set", async () => {
|
||||
mounted = mountView({
|
||||
items: [
|
||||
{ key: "a", label: "conn-a", status: "success", durationMs: 12, result: result([], [], 3) },
|
||||
{ key: "b", label: "conn-b", status: "failed", durationMs: 9, errorMessage: "syntax error" },
|
||||
{ key: "c", label: "conn-c", status: "pending_commit", durationMs: 5, result: result([], [], 7), transaction: { canCommit: true } },
|
||||
],
|
||||
transactional: true,
|
||||
sql: "UPDATE t SET amount = 0",
|
||||
durationMs: 40,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
exportButton("[data-merge-export-all]").click();
|
||||
await vi.waitFor(() => expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledTimes(1));
|
||||
|
||||
const sheet = (mocks.exportQueryResultsXlsx.mock.calls[0] as [string, Sheet[]])[1][0];
|
||||
expect(sheet?.columns).toEqual([
|
||||
i18n.global.t("multiDbExecute.mergedSourceColumn"),
|
||||
i18n.global.t("multiDbExecute.statusColumn"),
|
||||
i18n.global.t("multiDbExecute.txnColumn"),
|
||||
i18n.global.t("multiDbExecute.affectedRowsColumn"),
|
||||
i18n.global.t("multiDbExecute.durationColumn"),
|
||||
i18n.global.t("multiDbExecute.errorColumn"),
|
||||
]);
|
||||
expect(sheet?.rows.map((row) => row[0])).toEqual(["conn-a", "conn-b", "conn-c"]);
|
||||
// A statement that ran inside a transaction still reports its row count.
|
||||
expect(sheet?.rows[0]?.[3]).toBe(3);
|
||||
expect(sheet?.rows[2]?.[3]).toBe(7);
|
||||
expect(sheet?.rows[1]?.[3]).toBe("");
|
||||
expect(String(sheet?.rows[1]?.[5])).toBe("syntax error");
|
||||
expect(sheet?.rows[0]?.[2]).toBe(i18n.global.t("multiDbExecute.txnCommitted"));
|
||||
expect(sheet?.rows[2]?.[2]).toBe(i18n.global.t("multiDbExecute.txnOpen"));
|
||||
});
|
||||
|
||||
it("keeps a single-sheet workbook when the batch carries no SQL context", async () => {
|
||||
mounted = mountView({
|
||||
items: [{ key: "a", label: "conn-a", status: "success", result: result(["id"], [[1]]) }],
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
exportButton("[data-merge-export-all]").click();
|
||||
await vi.waitFor(() => expect(mocks.exportQueryResultXlsx).toHaveBeenCalledTimes(1));
|
||||
|
||||
const [outputPath, sheetName, columns, , , rows] = mocks.exportQueryResultXlsx.mock.calls[0] as [string, string, string[], string[], unknown, unknown[][]];
|
||||
expect(outputPath).toMatch(/\.xlsx$/);
|
||||
expect(sheetName).toBe(i18n.global.t("multiDbExecute.mergedSheetName"));
|
||||
expect(columns).toEqual([i18n.global.t("multiDbExecute.mergedSourceColumn"), "id"]);
|
||||
expect(rows).toEqual([["conn-a", 1]]);
|
||||
expect(mocks.exportQueryResultsXlsx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to export an empty merged table", async () => {
|
||||
mounted = mountView({ items: [{ key: "a", label: "conn-a", status: "success", result: result(["id"], []) }] });
|
||||
await nextTick();
|
||||
|
||||
exportButton("[data-merge-export-all]").click();
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.exportQueryResultsXlsx).not.toHaveBeenCalled();
|
||||
expect(mocks.exportQueryResultXlsx).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledWith(i18n.global.t("multiDbExecute.mergedEmpty"), 3000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
|
||||
import { buildXlsxWorkbook } from "@/lib/export/xlsxExport";
|
||||
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE, mergeMultiSourceResults } from "@/lib/query/multiSourceResult";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function result(columns: string[], rows: (string | number | null)[][]): QueryResult {
|
||||
return { columns, rows, affected_rows: rows.length, execution_time_ms: 1 };
|
||||
}
|
||||
|
||||
function mountView(items: Array<{ key: string; label: string; result?: QueryResult; status?: "success" | "failed" | "skipped" | "cancelled" | "not_executed" | "running" | "pending"; durationMs?: number; errorMessage?: string }>, props: Record<string, unknown> = {}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp({ render: () => h(MultiSourceMergeView, { items, ...props }) });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
return { host, app };
|
||||
}
|
||||
|
||||
let mounted: ReturnType<typeof mountView> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.app.unmount();
|
||||
mounted?.host.remove();
|
||||
mounted = undefined;
|
||||
});
|
||||
|
||||
describe("MultiSourceMergeView", () => {
|
||||
it("unions source columns, labels every row with its source and pads missing columns", async () => {
|
||||
mounted = mountView([
|
||||
{ key: "a", label: "conn-a", result: result(["id", "amount"], [[1, 10]]) },
|
||||
{ key: "b", label: "conn-b", result: result(["amount", "region"], [[20, "north"]]) },
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
const headers = [...mounted.host.querySelectorAll("thead th")].map((cell) => cell.textContent?.trim());
|
||||
expect(headers.slice(1)).toEqual(["id", "amount", "region"]);
|
||||
|
||||
const rows = [...mounted.host.querySelectorAll("tbody tr")].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.trim()));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]?.slice(1)).toEqual(["1", "10", ""]);
|
||||
expect(rows[1]?.slice(1)).toEqual(["", "20", "north"]);
|
||||
expect(rows[0]?.[0]).toContain("conn-a");
|
||||
expect(rows[1]?.[0]).toContain("conn-b");
|
||||
});
|
||||
|
||||
it("sums only all-numeric columns in the footer", async () => {
|
||||
mounted = mountView([
|
||||
{ key: "a", label: "conn-a", result: result(["id", "amount", "note"], [[1, 10, "x"]]) },
|
||||
{ key: "b", label: "conn-b", result: result(["id", "amount", "note"], [[2, "n/a", "y"]]) },
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
const footer = [...(mounted.host.querySelector("tfoot")?.querySelectorAll("td") ?? [])].map((cell) => cell.textContent?.trim());
|
||||
// First cell is the SUM label, then one cell per data column.
|
||||
expect(footer).toHaveLength(4);
|
||||
expect(footer[1]).toBe("3");
|
||||
// `amount` mixes 10 and "n/a", so its column is not a total.
|
||||
expect(footer[2]).toBe("");
|
||||
expect(footer[3]).toBe("");
|
||||
});
|
||||
|
||||
it("reports the merged source count and row count", async () => {
|
||||
mounted = mountView([
|
||||
{ key: "a", label: "conn-a", result: result(["id"], [[1]]) },
|
||||
{ key: "b", label: "conn-b", result: result(["id"], [[2]]) },
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
const legend = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? "";
|
||||
expect(legend).toContain("2");
|
||||
});
|
||||
|
||||
it("states the per-source row cap and warns explicitly when a source hit it", async () => {
|
||||
const cappedRows = Array.from({ length: MULTI_SOURCE_MAX_ROWS_PER_SOURCE }, (_, index) => [index, index]);
|
||||
mounted = mountView([
|
||||
{ key: "a", label: "conn-a", result: result(["id", "amount"], cappedRows) },
|
||||
{ key: "b", label: "conn-b", result: result(["id", "amount"], [[1, 2]]) },
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
const legend = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? "";
|
||||
// The cap is always stated, and reaching it is called out in amber.
|
||||
expect(legend).toContain(String(MULTI_SOURCE_MAX_ROWS_PER_SOURCE));
|
||||
expect(mounted.host.querySelector("[data-merge-source-cap-reached]")?.textContent).toContain(String(MULTI_SOURCE_MAX_ROWS_PER_SOURCE));
|
||||
});
|
||||
|
||||
it("does not warn about the per-source cap while every source stays below it", async () => {
|
||||
mounted = mountView([
|
||||
{ key: "a", label: "conn-a", result: result(["id"], [[1], [2]]) },
|
||||
{ key: "b", label: "conn-b", result: result(["id"], [[3]]) },
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.host.querySelector("[data-merge-source-cap-reached]")).toBeNull();
|
||||
expect(mounted.host.querySelector("[data-merge-truncated]")).toBeNull();
|
||||
});
|
||||
|
||||
it("feeds the Excel writer a workbook-ready payload", () => {
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "conn-a", result: result(["id", "amount", "note"], [[1, 10, "x"]]) },
|
||||
{ key: "b", label: "conn-b", result: result(["amount"], [[2.5]]) },
|
||||
],
|
||||
{ sourceColumnLabel: "来源" },
|
||||
);
|
||||
|
||||
// Same call shape the export action uses (no column types available).
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: "合并结果",
|
||||
columns: merged.columns,
|
||||
columnTypes: merged.columns.map(() => ""),
|
||||
rows: merged.rows,
|
||||
numericColumnRightAlign: true,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
expect(workbook.byteLength).toBeGreaterThan(0);
|
||||
// XLSX is a zip container; the local file header magic proves the writer
|
||||
// accepted the merged rows (nulls and mixed types included).
|
||||
expect(String.fromCharCode(...workbook.subarray(0, 2))).toBe("PK");
|
||||
});
|
||||
|
||||
it("shows the executed statement and the batch duration in the header", async () => {
|
||||
mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }], { sql: "SELECT id FROM sales", durationMs: 1250 });
|
||||
await nextTick();
|
||||
|
||||
const header = mounted.host.querySelector("[data-multi-source-merge-view]")?.textContent ?? "";
|
||||
expect(header).toContain("SELECT id FROM sales");
|
||||
expect(header).toContain("1.3 s");
|
||||
expect(mounted.host.querySelector("[data-merge-sql]")?.getAttribute("title")).toBe("SELECT id FROM sales");
|
||||
});
|
||||
|
||||
it("shows the local execution timestamp of the batch", async () => {
|
||||
const executedAt = new Date(2026, 8, 21, 14, 32, 5).getTime();
|
||||
mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }], { executedAt });
|
||||
await nextTick();
|
||||
|
||||
const chip = mounted.host.querySelector("[data-merge-executed-at]")?.textContent ?? "";
|
||||
expect(chip).toContain("2026-09-21 14:32:05");
|
||||
// Timezone stays explicit, like the app's other local timestamps.
|
||||
expect(chip).toMatch(/[+-]\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it("omits the SQL and duration chips when the host does not provide them", async () => {
|
||||
mounted = mountView([{ key: "a", label: "conn-a", result: result(["id"], [[1]]) }]);
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.host.querySelector("[data-merge-sql]")).toBeNull();
|
||||
expect(mounted.host.querySelector("[data-merge-duration]")).toBeNull();
|
||||
expect(mounted.host.querySelector("[data-merge-executed-at]")).toBeNull();
|
||||
});
|
||||
|
||||
describe("write statements without a result set", () => {
|
||||
function writeItems() {
|
||||
return [
|
||||
{
|
||||
key: "a",
|
||||
label: "conn-a",
|
||||
status: "success" as const,
|
||||
durationMs: 41,
|
||||
result: { columns: [], rows: [], affected_rows: 12, execution_time_ms: 41 },
|
||||
},
|
||||
{
|
||||
key: "b",
|
||||
label: "conn-b",
|
||||
status: "failed" as const,
|
||||
durationMs: 17,
|
||||
errorMessage: "no such table: t_order",
|
||||
result: { columns: ["Error"], rows: [["no such table: t_order"]], affected_rows: 0, execution_time_ms: 17, execution_error: true as const },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("lists one row per target with status, affected rows and the error", async () => {
|
||||
mounted = mountView(writeItems());
|
||||
await nextTick();
|
||||
|
||||
const table = mounted.host.querySelector("[data-merge-target-results]");
|
||||
expect(table).not.toBeNull();
|
||||
const rows = [...(table?.querySelectorAll("tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/g, " ").trim()));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]?.[0]).toBe("conn-a");
|
||||
expect(rows[0]?.[2]).toContain("12");
|
||||
expect(rows[1]?.[0]).toBe("conn-b");
|
||||
expect(rows[1]?.[2]).toBe("—");
|
||||
expect(rows[1]?.[4]).toContain("no such table");
|
||||
});
|
||||
|
||||
it("summarizes the batch by targets and affected rows instead of read statistics", async () => {
|
||||
mounted = mountView(writeItems());
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.host.querySelector("[data-merge-write-summary]")?.textContent?.trim()).toBe(i18n.global.t("multiDbExecute.mergedWriteSummary", { targets: 2, rows: 12 }));
|
||||
// A write batch must not advertise the per-source read cap it never uses.
|
||||
expect(mounted.host.textContent).not.toContain(i18n.global.t("multiDbExecute.mergedSourceRowCap", { count: MULTI_SOURCE_MAX_ROWS_PER_SOURCE }));
|
||||
// The column already names the count, so the cell carries the bare number.
|
||||
const affected = [...(mounted.host.querySelectorAll("[data-merge-target-results] tbody tr") ?? [])].map((row) => [...row.querySelectorAll("td")][2]?.textContent?.trim());
|
||||
expect(affected).toEqual(["12", "—"]);
|
||||
});
|
||||
|
||||
it("offers a re-run only for targets that did not succeed", async () => {
|
||||
const rerun: string[] = [];
|
||||
mounted = mountView(writeItems(), { onRerunTarget: (key: string) => rerun.push(key) });
|
||||
await nextTick();
|
||||
|
||||
const buttons = [...mounted.host.querySelectorAll("[data-merge-rerun]")];
|
||||
expect(buttons).toHaveLength(1);
|
||||
(buttons[0] as HTMLButtonElement).click();
|
||||
await nextTick();
|
||||
expect(rerun).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("blocks re-running while the batch is still busy", async () => {
|
||||
mounted = mountView(writeItems(), { rerunDisabled: true });
|
||||
await nextTick();
|
||||
|
||||
const rerunButton = mounted.host.querySelector("[data-merge-rerun]") as HTMLButtonElement | null;
|
||||
expect(rerunButton?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import MultiSourceMergeView from "@/components/editor/MultiSourceMergeView.vue";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function writeResult(affectedRows: number): QueryResult {
|
||||
return { columns: [], rows: [], affected_rows: affectedRows, execution_time_ms: 7 };
|
||||
}
|
||||
|
||||
/** One batch that ran inside per-target transactions. */
|
||||
function items() {
|
||||
return [
|
||||
{ key: "a", label: "conn-a", status: "pending_commit" as const, durationMs: 12, result: writeResult(3), transaction: { canCommit: true } },
|
||||
// A target whose session survived a rollback failure: it can still be rolled back.
|
||||
{ key: "b", label: "conn-b", status: "failed" as const, durationMs: 9, errorMessage: "syntax error", transaction: { canCommit: false } },
|
||||
{ key: "c", label: "conn-c", status: "success" as const, durationMs: 5, result: writeResult(0) },
|
||||
{ key: "d", label: "conn-d", status: "rolled_back" as const, durationMs: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
function mountView(props: Record<string, unknown>) {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp({ render: () => h(MultiSourceMergeView, props) });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
return { host, app };
|
||||
}
|
||||
|
||||
let mounted: ReturnType<typeof mountView> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.app.unmount();
|
||||
mounted?.host.remove();
|
||||
mounted = undefined;
|
||||
});
|
||||
|
||||
describe("MultiSourceMergeView transactions", () => {
|
||||
it("shows the transaction state per target and offers commit/rollback only while open", async () => {
|
||||
mounted = mountView({ items: items(), transactional: true });
|
||||
await nextTick();
|
||||
|
||||
const states = [...mounted.host.querySelectorAll("[data-merge-txn-state]")].map((cell) => cell.textContent?.trim());
|
||||
expect(states).toEqual([i18n.global.t("multiDbExecute.txnOpen"), i18n.global.t("multiDbExecute.txnOpen"), i18n.global.t("multiDbExecute.txnCommitted"), i18n.global.t("multiDbExecute.txnRolledBack")]);
|
||||
|
||||
// Two open sessions: each offers one commit and one rollback button.
|
||||
expect(mounted.host.querySelectorAll("[data-merge-commit]")).toHaveLength(2);
|
||||
expect(mounted.host.querySelectorAll("[data-merge-rollback]")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("blocks committing a target that errored and counts only committable targets", async () => {
|
||||
mounted = mountView({ items: items(), transactional: true });
|
||||
await nextTick();
|
||||
|
||||
const commitButtons = [...mounted.host.querySelectorAll("[data-merge-commit]")] as HTMLButtonElement[];
|
||||
expect(commitButtons[0]?.disabled).toBe(false);
|
||||
// conn-b failed, so its transaction has nothing to write but can be discarded.
|
||||
expect(commitButtons[1]?.disabled).toBe(true);
|
||||
|
||||
const commitAll = mounted.host.querySelector("[data-merge-commit-all]") as HTMLButtonElement;
|
||||
expect(commitAll.textContent).toContain("1");
|
||||
});
|
||||
|
||||
it("blocks every transaction control while one of them is settling", async () => {
|
||||
const settling = items();
|
||||
settling[0] = { ...settling[0]!, transaction: { canCommit: true, settling: true } } as (typeof settling)[number];
|
||||
mounted = mountView({ items: settling, transactional: true });
|
||||
await nextTick();
|
||||
|
||||
const commitButtons = [...mounted.host.querySelectorAll("[data-merge-commit]")] as HTMLButtonElement[];
|
||||
const rollbackButtons = [...mounted.host.querySelectorAll("[data-merge-rollback]")] as HTMLButtonElement[];
|
||||
expect(commitButtons[0]?.disabled).toBe(true);
|
||||
expect(rollbackButtons[0]?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("emits per-target and batch transaction actions", async () => {
|
||||
const events: string[] = [];
|
||||
mounted = mountView({
|
||||
items: items(),
|
||||
transactional: true,
|
||||
onCommitTarget: (key: string) => events.push(`commit:${key}`),
|
||||
onRollbackTarget: (key: string) => events.push(`rollback:${key}`),
|
||||
onCommitAll: () => events.push("commit-all"),
|
||||
onRollbackAll: () => events.push("rollback-all"),
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
(mounted.host.querySelectorAll("[data-merge-commit]")[0] as HTMLButtonElement).click();
|
||||
(mounted.host.querySelectorAll("[data-merge-rollback]")[1] as HTMLButtonElement).click();
|
||||
(mounted.host.querySelector("[data-merge-commit-all]") as HTMLButtonElement).click();
|
||||
(mounted.host.querySelector("[data-merge-rollback-all]") as HTMLButtonElement).click();
|
||||
await nextTick();
|
||||
|
||||
expect(events).toEqual(["commit:a", "rollback:b", "commit-all", "rollback-all"]);
|
||||
});
|
||||
|
||||
it("hides every transaction control for an auto-commit batch", async () => {
|
||||
const autoCommit = items().map(({ transaction: _transaction, ...item }) => ({ ...item, status: item.status === "pending_commit" ? ("success" as const) : item.status }));
|
||||
mounted = mountView({ items: autoCommit, transactional: false });
|
||||
await nextTick();
|
||||
|
||||
expect(mounted.host.querySelector("[data-merge-txn-state]")).toBeNull();
|
||||
expect(mounted.host.querySelector("[data-merge-commit-all]")).toBeNull();
|
||||
expect(mounted.host.querySelector("[data-merge-rollback-all]")).toBeNull();
|
||||
// A failed target without a session keeps its re-run affordance instead.
|
||||
expect(mounted.host.querySelector("[data-merge-rerun]")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
const ConnectionDialog = defineAsyncComponent(() => import("@/components/connection/ConnectionDialog.vue"));
|
||||
const DangerConfirmDialog = defineAsyncComponent(() => import("@/components/editor/DangerConfirmDialog.vue"));
|
||||
const MultiDbDangerConfirmDialog = defineAsyncComponent(() => import("@/components/layout/MultiDbDangerConfirmDialog.vue"));
|
||||
const SqlParameterDialog = defineAsyncComponent(() => import("@/components/editor/SqlParameterDialog.vue"));
|
||||
const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue"));
|
||||
const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue"));
|
||||
@@ -126,16 +127,6 @@ watch(
|
||||
if (pending) unlockDuration.value = WRITE_UNLOCK_ONE_MINUTE_SECS;
|
||||
},
|
||||
);
|
||||
const multiDbDangerDetails = computed(() => {
|
||||
const request = sqlExecutionDangerStore.pending;
|
||||
if (!request) return "";
|
||||
return [request.targetLabel || request.connectionName, request.database].filter(Boolean).join("\n");
|
||||
});
|
||||
const multiDbDangerMessage = computed(() => {
|
||||
const request = sqlExecutionDangerStore.pending;
|
||||
return request?.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message");
|
||||
});
|
||||
|
||||
const editConfig = computed(() => {
|
||||
const id = connectionStore.editingConnectionId;
|
||||
if (!id) return undefined;
|
||||
@@ -230,19 +221,7 @@ watch(
|
||||
</fieldset>
|
||||
</template>
|
||||
</DangerConfirmDialog>
|
||||
<DangerConfirmDialog
|
||||
v-if="sqlExecutionDangerStore.pending"
|
||||
:open="true"
|
||||
:title="t('multiDbExecute.dangerTitle')"
|
||||
:message="multiDbDangerMessage"
|
||||
:details-text="multiDbDangerDetails"
|
||||
:sql="sqlExecutionDangerStore.pending.sql"
|
||||
:confirm-label="t('multiDbExecute.dangerConfirm')"
|
||||
:show-suppress-toggle="false"
|
||||
:close-on-confirm="false"
|
||||
@update:open="(open) => !open && sqlExecutionDangerStore.cancel()"
|
||||
@confirm="sqlExecutionDangerStore.confirm()"
|
||||
/>
|
||||
<MultiDbDangerConfirmDialog v-if="sqlExecutionDangerStore.pending" :request="sqlExecutionDangerStore.pending" @confirm="sqlExecutionDangerStore.confirm()" @cancel="sqlExecutionDangerStore.cancel()" />
|
||||
<SqlParameterDialog
|
||||
v-if="showSqlParameterDialog"
|
||||
:open="showSqlParameterDialog"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, Copy } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { createExecutionConfirmCode, matchesExecutionConfirmCode } from "@/lib/query/executionConfirmCode";
|
||||
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
|
||||
|
||||
/**
|
||||
* Confirmation for one multi-database batch. A batch runs the same statement
|
||||
* against every data source, so the operator retypes a code that is generated
|
||||
* per prompt: a stray click or a held Enter cannot fan a destructive statement
|
||||
* out to all connections.
|
||||
*/
|
||||
const props = defineProps<{ request: SqlExecutionDangerRequest }>();
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const details = computed(() => {
|
||||
const headline = [props.request.targetLabel || props.request.connectionName, props.request.database].filter(Boolean).join(" · ");
|
||||
const targets = props.request.targets ?? [];
|
||||
if (targets.length <= 1) return headline;
|
||||
return [headline, "", t("multiDbExecute.dangerTargets", { count: targets.length }), ...targets.map((target) => `• ${target}`)].join("\n");
|
||||
});
|
||||
|
||||
const message = computed(() => (props.request.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message")));
|
||||
|
||||
const code = ref("");
|
||||
const codeInput = ref("");
|
||||
const codeCopied = ref(false);
|
||||
const confirmDisabled = computed(() => !matchesExecutionConfirmCode(codeInput.value, code.value));
|
||||
|
||||
// Every prompt gets its own code, and the previous attempt is cleared.
|
||||
watch(
|
||||
() => props.request,
|
||||
() => {
|
||||
code.value = createExecutionConfirmCode();
|
||||
codeInput.value = "";
|
||||
codeCopied.value = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function copyCode(): Promise<void> {
|
||||
await copyToClipboard(code.value);
|
||||
codeCopied.value = true;
|
||||
window.setTimeout(() => {
|
||||
codeCopied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DangerConfirmDialog
|
||||
:open="true"
|
||||
:title="t('multiDbExecute.dangerTitle')"
|
||||
:message="message"
|
||||
:details-text="details"
|
||||
:sql="request.sql"
|
||||
:confirm-label="t('multiDbExecute.dangerConfirm')"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
:show-suppress-toggle="false"
|
||||
:close-on-confirm="false"
|
||||
@update:open="(open) => !open && emit('cancel')"
|
||||
@confirm="emit('confirm')"
|
||||
>
|
||||
<template #options>
|
||||
<div class="mb-3 space-y-1.5 rounded-md border bg-muted/20 px-3 py-2.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label for="multi-db-danger-code" class="text-xs leading-5 text-muted-foreground">{{ t("multiDbExecute.dangerConfirmCodePrompt") }}</Label>
|
||||
<Button variant="ghost" size="icon-xs" class="h-6 w-6 text-muted-foreground" :title="t('multiDbExecute.dangerConfirmCodeCopy')" data-multi-db-danger-code-copy @click="copyCode">
|
||||
<Check v-if="codeCopied" class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="font-mono text-lg leading-6 tracking-[0.3em] text-foreground" data-multi-db-danger-code-value>{{ code }}</p>
|
||||
<Input id="multi-db-danger-code" v-model="codeInput" data-multi-db-danger-code class="h-8 font-mono tracking-widest" autocomplete="off" spellcheck="false" :placeholder="t('multiDbExecute.dangerConfirmCodePlaceholder')" />
|
||||
<!-- The whole batch is answered by this one code, so the instruction
|
||||
has to stand out next to the input instead of blending into it. -->
|
||||
<p class="text-xs leading-4 font-semibold text-destructive" data-multi-db-danger-code-once>{{ t("multiDbExecute.dangerConfirmCodeOnce") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</DangerConfirmDialog>
|
||||
</template>
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, h, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import MultiDbDangerConfirmDialog from "@/components/layout/MultiDbDangerConfirmDialog.vue";
|
||||
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
|
||||
|
||||
function request(overrides: Partial<SqlExecutionDangerRequest> = {}): SqlExecutionDangerRequest {
|
||||
return {
|
||||
sql: "UPDATE accounts SET balance = 0",
|
||||
kind: "sql",
|
||||
connectionName: "prod-a",
|
||||
database: "shop",
|
||||
targetLabel: "prod-a / shop",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mountDialog(props: Record<string, unknown>) {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp({ render: () => h(MultiDbDangerConfirmDialog, props) });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
return { host, app };
|
||||
}
|
||||
|
||||
let mounted: { host: HTMLElement; app: App } | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.app.unmount();
|
||||
mounted?.host.remove();
|
||||
mounted = undefined;
|
||||
mocks.copyToClipboard.mockClear();
|
||||
});
|
||||
|
||||
/** The dialog content is portaled to the document body. */
|
||||
function codeValue(): string {
|
||||
return document.body.querySelector("[data-multi-db-danger-code-value]")?.textContent?.trim() ?? "";
|
||||
}
|
||||
|
||||
function codeInput(): HTMLInputElement {
|
||||
const input = document.body.querySelector<HTMLInputElement>("[data-multi-db-danger-code]");
|
||||
if (!input) throw new Error("Missing code input");
|
||||
return input;
|
||||
}
|
||||
|
||||
function confirmButton(): HTMLButtonElement {
|
||||
const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("multiDbExecute.dangerConfirm"));
|
||||
if (!button) throw new Error("Missing confirm button");
|
||||
return button;
|
||||
}
|
||||
|
||||
async function typeCode(value: string) {
|
||||
const input = codeInput();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input"));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("multi-database batch confirmation", () => {
|
||||
it("lists every data source of the batch next to the target that prompted", async () => {
|
||||
mounted = mountDialog({ request: request({ targets: ["prod-a / shop", "prod-b / shop", "prod-c / shop"] }) });
|
||||
await nextTick();
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("prod-a / shop");
|
||||
expect(text).toContain(i18n.global.t("multiDbExecute.dangerTargets", { count: 3 }));
|
||||
expect(text).toContain("• prod-b / shop");
|
||||
expect(text).toContain("• prod-c / shop");
|
||||
expect(document.body.textContent).toContain("UPDATE accounts SET balance = 0");
|
||||
});
|
||||
|
||||
it("keeps the headline only when the statement touches a single source", async () => {
|
||||
mounted = mountDialog({ request: request({ targets: ["prod-a / shop"] }) });
|
||||
await nextTick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(i18n.global.t("multiDbExecute.dangerTargets", { count: 1 }));
|
||||
expect(document.body.textContent).toContain("prod-a / shop");
|
||||
});
|
||||
|
||||
it("unlocks the confirm button only for the code of this prompt", async () => {
|
||||
mounted = mountDialog({ request: request() });
|
||||
await nextTick();
|
||||
|
||||
const code = codeValue();
|
||||
expect(code).toHaveLength(6);
|
||||
expect(confirmButton().disabled).toBe(true);
|
||||
|
||||
await typeCode("WRONG1");
|
||||
expect(confirmButton().disabled).toBe(true);
|
||||
|
||||
await typeCode(code);
|
||||
expect(confirmButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a code typed in lower case", async () => {
|
||||
mounted = mountDialog({ request: request() });
|
||||
await nextTick();
|
||||
|
||||
await typeCode(codeValue().toLowerCase());
|
||||
expect(confirmButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("copies the displayed code and clears the typed attempt for a new prompt", async () => {
|
||||
mounted = mountDialog({ request: request() });
|
||||
await nextTick();
|
||||
|
||||
const firstCode = codeValue();
|
||||
await typeCode(firstCode);
|
||||
(document.body.querySelector("[data-multi-db-danger-code-copy]") as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(firstCode));
|
||||
|
||||
// The next prompt is a fresh confirmation, not the previous one re-armed.
|
||||
mounted.app.unmount();
|
||||
mounted.host.remove();
|
||||
mounted = mountDialog({ request: request({ sql: "DELETE FROM accounts" }) });
|
||||
await nextTick();
|
||||
const nextCode = codeValue();
|
||||
expect(nextCode).toHaveLength(6);
|
||||
expect(codeInput().value).toBe("");
|
||||
expect(confirmButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("reports confirm and cancel back to the batch", async () => {
|
||||
const events: string[] = [];
|
||||
mounted = mountDialog({
|
||||
request: request(),
|
||||
onConfirm: () => events.push("confirm"),
|
||||
onCancel: () => events.push("cancel"),
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// Cancelling the prompt answers the batch without running it.
|
||||
const cancel = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("dangerDialog.cancel"));
|
||||
cancel?.click();
|
||||
await nextTick();
|
||||
expect(events).toEqual(["cancel"]);
|
||||
|
||||
await typeCode(codeValue());
|
||||
confirmButton().click();
|
||||
await nextTick();
|
||||
expect(events).toEqual(["cancel", "confirm"]);
|
||||
});
|
||||
|
||||
it("still confirms the single-connection danger dialog without any code", async () => {
|
||||
const { default: DangerConfirmDialog } = await import("@/components/editor/DangerConfirmDialog.vue");
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const confirms: number[] = [];
|
||||
const app = createApp({ render: () => h(DangerConfirmDialog, { open: true, title: "Drop table", message: "This cannot be undone", onConfirm: () => confirms.push(1) }) });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
try {
|
||||
await nextTick();
|
||||
expect(document.body.querySelector("[data-multi-db-danger-code]")).toBeNull();
|
||||
const confirm = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === i18n.global.t("dangerDialog.confirm"));
|
||||
expect(confirm).toBeDefined();
|
||||
confirm?.click();
|
||||
await nextTick();
|
||||
expect(confirms).toEqual([1]);
|
||||
} finally {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useMultiDbExecution } from "@/composables/useMultiDbExecution";
|
||||
import { useMultiDbExecution, type MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
|
||||
import type { MultiDbTargetExecutionResult } from "@/types/sqlExecution";
|
||||
|
||||
const targets = [
|
||||
{ connectionId: "conn-1", database: "app" },
|
||||
@@ -9,6 +10,181 @@ const targets = [
|
||||
] as const;
|
||||
|
||||
describe("useMultiDbExecution", () => {
|
||||
it.each(["serial", "parallel"] as const)("tracks a single-target retry in a %s batch while preserving successful targets and manual transactions", async (mode) => {
|
||||
let release!: (result: MultiDbTargetExecutionResult) => void;
|
||||
const retryResult = new Promise<MultiDbTargetExecutionResult>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const finish = vi.fn().mockResolvedValue(undefined);
|
||||
const validateTarget = vi.fn().mockResolvedValue({ valid: true });
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async ({ target }) => (target.connectionId === "conn-1" ? { status: "pending_commit", transaction: { canCommit: true, finish } } : { status: "failed", durationMs: 999, errorMessage: "original failure" }));
|
||||
const executor = useMultiDbExecution({ executeTarget, validateTarget }, { sourceTabId: "source" });
|
||||
const batch = (await executor.start("UPDATE t SET n = 1", targets.slice(0, 2), { manualTransaction: true, sourceOffset: 17 }, mode))!;
|
||||
const [successful, failed] = batch.items;
|
||||
const originalContext = executeTarget.mock.calls[1]![0].context;
|
||||
const originalStartedAt = batch.startedAt;
|
||||
executeTarget.mockImplementationOnce(() => retryResult);
|
||||
|
||||
const retry = executor.retry(failed!.id);
|
||||
expect(executor.isRunning.value).toBe(true);
|
||||
expect(batch.status).toBe("running");
|
||||
expect(batch.completedAt).toBeUndefined();
|
||||
expect(batch.durationMs).toBeUndefined();
|
||||
expect(failed).toMatchObject({ status: "running", errorMessage: undefined, durationMs: undefined, completedAt: undefined });
|
||||
executor.reset();
|
||||
expect(executor.batch.value?.id).toBe(batch.id);
|
||||
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
|
||||
await executor.retry(failed!.id);
|
||||
expect(await executor.finishTransaction(successful!.id, "commit")).toBe(false);
|
||||
await Promise.resolve();
|
||||
expect(executeTarget).toHaveBeenCalledTimes(3);
|
||||
expect(validateTarget).toHaveBeenLastCalledWith(targets[1]);
|
||||
expect(executeTarget.mock.calls[2]![0]).toMatchObject({ target: targets[1], sourceTabId: "source", sql: batch.sql, scopeId: batch.id });
|
||||
expect(executeTarget.mock.calls[2]![0].context).toBe(originalContext);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
release({ status: "pending_commit", durationMs: 12, transaction: { canCommit: true, finish } });
|
||||
await retry;
|
||||
expect(batch.startedAt).toBe(originalStartedAt);
|
||||
expect(batch.status).toBe("completed");
|
||||
expect(batch.items.map((item) => item.status)).toEqual(["pending_commit", "pending_commit"]);
|
||||
expect(failed!.durationMs).toBe(12);
|
||||
await executor.retry(failed!.id);
|
||||
expect(executeTarget).toHaveBeenCalledTimes(3);
|
||||
expect(await executor.finishTransaction(failed!.id, "commit")).toBe(true);
|
||||
expect(await executor.finishTransaction(successful!.id, "rollback")).toBe(true);
|
||||
expect(finish.mock.calls.map(([action]) => action)).toEqual(["commit", "rollback"]);
|
||||
executor.reset();
|
||||
expect(executor.batch.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["cancelAndRollback", "dispose"] as const)("%s waits for a deferred retry and rolls back its late transaction", async (cleanup) => {
|
||||
let release!: (result: MultiDbTargetExecutionResult) => void;
|
||||
const retryResult = new Promise<MultiDbTargetExecutionResult>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const finish = vi.fn().mockResolvedValue(undefined);
|
||||
const executeTarget = vi
|
||||
.fn<MultiDbExecutionAdapter["executeTarget"]>()
|
||||
.mockResolvedValueOnce({ status: "failed" })
|
||||
.mockImplementationOnce(() => retryResult);
|
||||
const cancelPending = vi.fn().mockRejectedValue(new Error("confirmation already closed"));
|
||||
const cancelTarget = vi.fn().mockRejectedValue(new Error("cancel unavailable"));
|
||||
const executor = useMultiDbExecution({ executeTarget, cancelPending, cancelTarget }, { sourceTabId: "source" });
|
||||
const batch = (await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true }))!;
|
||||
const retry = executor.retry(batch.items[0]!.id);
|
||||
const isCancellationRequested = executeTarget.mock.calls[1]![0].isCancellationRequested;
|
||||
expect(isCancellationRequested()).toBe(false);
|
||||
let cleaned = false;
|
||||
const cleaning = executor[cleanup]().then((result) => {
|
||||
cleaned = true;
|
||||
return result;
|
||||
});
|
||||
await vi.waitFor(() => expect(cancelTarget).toHaveBeenCalledExactlyOnceWith("source", batch.id));
|
||||
expect(cancelPending).toHaveBeenCalledExactlyOnceWith(batch.id);
|
||||
expect(isCancellationRequested()).toBe(true);
|
||||
expect(batch.status).toBe("cancelling");
|
||||
executor.reset();
|
||||
expect(executor.batch.value?.id).toBe(batch.id);
|
||||
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
|
||||
expect(cleaned).toBe(false);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
release({ status: "pending_commit", transaction: { canCommit: true, finish } });
|
||||
await retry;
|
||||
expect(await cleaning).toBe(true);
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("rollback");
|
||||
expect(batch.status).toBe("cancelled");
|
||||
expect(batch.items[0]!.status).toBe("rolled_back");
|
||||
expect(executor.hasTransactions.value).toBe(false);
|
||||
if (cleanup === "dispose") {
|
||||
await executor.retry(batch.items[0]!.id);
|
||||
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
|
||||
expect(executeTarget).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a retry transaction when disposal cannot roll it back", async () => {
|
||||
let release!: (result: MultiDbTargetExecutionResult) => void;
|
||||
const retryResult = new Promise<MultiDbTargetExecutionResult>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const finish = vi.fn().mockRejectedValue(new Error("rollback unavailable"));
|
||||
const executeTarget = vi
|
||||
.fn<MultiDbExecutionAdapter["executeTarget"]>()
|
||||
.mockResolvedValueOnce({ status: "failed" })
|
||||
.mockImplementationOnce(() => retryResult);
|
||||
const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" });
|
||||
const batch = (await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true }))!;
|
||||
const retry = executor.retry(batch.items[0]!.id);
|
||||
const disposing = executor.dispose();
|
||||
release({ status: "failed", transaction: { canCommit: false, finish } });
|
||||
await retry;
|
||||
expect(await disposing).toBe(false);
|
||||
expect(batch.items[0]!.errorMessage).toBe("rollback unavailable");
|
||||
expect(executor.hasTransactions.value).toBe(true);
|
||||
executor.reset();
|
||||
expect(executor.batch.value?.id).toBe(batch.id);
|
||||
await executor.retry(batch.items[0]!.id);
|
||||
expect(executeTarget).toHaveBeenCalledTimes(2);
|
||||
expect(await executor.finishTransaction(batch.items[0]!.id, "commit")).toBe(false);
|
||||
finish.mockResolvedValue(undefined);
|
||||
expect(await executor.rollbackPending()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears cancellation when retrying one target without executing the cancelled queue", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async () => {
|
||||
await gate;
|
||||
return { status: "failed" };
|
||||
});
|
||||
const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" });
|
||||
const run = executor.start("SELECT 1", targets);
|
||||
await executor.cancel();
|
||||
release();
|
||||
const batch = (await run)!;
|
||||
expect(batch.status).toBe("cancelled");
|
||||
executeTarget.mockImplementationOnce(async ({ isCancellationRequested }) => {
|
||||
expect(isCancellationRequested()).toBe(false);
|
||||
return { status: "success" };
|
||||
});
|
||||
await executor.retry(batch.items[0]!.id);
|
||||
expect(batch.cancelRequested).toBe(false);
|
||||
expect(batch.status).toBe("completed");
|
||||
expect(batch.items.map((item) => item.status)).toEqual(["success", "not_executed", "not_executed"]);
|
||||
await executor.retry(batch.items[0]!.id);
|
||||
await executor.retry("missing");
|
||||
expect(executeTarget).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["invalid", "throws", "cancelled"] as const)("revalidates retries and does not execute when validation is %s", async (outcome) => {
|
||||
let release!: (result: { valid: boolean; errorMessage?: string }) => void;
|
||||
const validation = new Promise<{ valid: boolean; errorMessage?: string }>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const validateTarget = vi.fn().mockResolvedValueOnce({ valid: true });
|
||||
const executeTarget = vi.fn<MultiDbExecutionAdapter["executeTarget"]>().mockResolvedValue({ status: "failed", durationMs: 999, errorMessage: "old failure" });
|
||||
const executor = useMultiDbExecution({ executeTarget, validateTarget }, { sourceTabId: "source" });
|
||||
const batch = (await executor.start("SELECT 1", [targets[0]]))!;
|
||||
if (outcome === "throws") validateTarget.mockRejectedValueOnce(new Error("validation unavailable"));
|
||||
else validateTarget.mockImplementationOnce(() => validation);
|
||||
const retry = executor.retry(batch.items[0]!.id);
|
||||
if (outcome === "cancelled") await executor.cancel();
|
||||
release({ valid: outcome === "cancelled", errorMessage: "target unavailable" });
|
||||
await retry;
|
||||
expect(executeTarget).toHaveBeenCalledTimes(1);
|
||||
expect(validateTarget).toHaveBeenCalledTimes(2);
|
||||
expect(batch.items[0]!.status).toBe(outcome === "cancelled" ? "cancelled" : "failed");
|
||||
expect(batch.items[0]!.durationMs).not.toBe(999);
|
||||
expect(batch.items[0]!.errorMessage).toBe(outcome === "throws" ? "validation unavailable" : outcome === "invalid" ? "target unavailable" : undefined);
|
||||
expect(executor.isRunning.value).toBe(false);
|
||||
executor.reset();
|
||||
expect(executor.batch.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains separate transactions until each target is explicitly settled", async () => {
|
||||
const commits = targets.map(() => vi.fn().mockResolvedValue(undefined));
|
||||
const executor = useMultiDbExecution(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isDangerousSql, requiresDatabaseSelection, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution";
|
||||
import { isDangerousSql, requiresDatabaseSelection, snapshotResultForMerge, supportsSqlTemplateParameters, useSqlExecution } from "../useSqlExecution";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -1612,3 +1612,35 @@ SELECT @value AS Message;`;
|
||||
expect(executeCurrentSql).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshotResultForMerge", () => {
|
||||
it("stays intact after the store releases the live payload in place", () => {
|
||||
const live = {
|
||||
columns: ["id", "amount"],
|
||||
rows: [
|
||||
[1, 10],
|
||||
[2, 20],
|
||||
],
|
||||
column_types: ["INT", "DECIMAL"],
|
||||
affected_rows: 2,
|
||||
execution_time_ms: 5,
|
||||
};
|
||||
const snapshot = snapshotResultForMerge(live as never)!;
|
||||
|
||||
// Mirrors queryStore.releaseResultObjectPayload: the arrays the merged view
|
||||
// reads are detached, so clearing the live result must not empty them.
|
||||
live.columns = [];
|
||||
live.rows = [];
|
||||
|
||||
expect(snapshot.columns).toEqual(["id", "amount"]);
|
||||
expect(snapshot.rows).toEqual([
|
||||
[1, 10],
|
||||
[2, 20],
|
||||
]);
|
||||
expect(snapshot.column_types).toEqual(["INT", "DECIMAL"]);
|
||||
});
|
||||
|
||||
it("passes undefined through", () => {
|
||||
expect(snapshotResultForMerge(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult";
|
||||
import { useSqlExecution } from "../useSqlExecution";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -313,4 +314,67 @@ describe("preservedResultIndex staleness theory", () => {
|
||||
expect(options).not.toHaveProperty("preserveActiveResultIndex");
|
||||
expect(tab.activeResultIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("reads one multi-database target up to the per-source row cap, not just the editor page", async () => {
|
||||
const sql = "SELECT 1 AS value";
|
||||
const tab = { ...queryTab(), sql };
|
||||
const connection = sqlServerConnection();
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
const executeTabSql = vi.spyOn(queryStore, "executeTabSql").mockImplementation(async () => true);
|
||||
vi.spyOn(queryStore, "getExecutionTab").mockReturnValue(tab);
|
||||
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => tab as QueryTab | undefined),
|
||||
activeConnection: computed(() => connection),
|
||||
executableSql: computed(() => sql),
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.executeTargetSql({ tab, connection, sql });
|
||||
|
||||
expect(MULTI_SOURCE_MAX_ROWS_PER_SOURCE).toBe(10_000);
|
||||
expect(executeTabSql.mock.calls[0]?.[2]).toMatchObject({ pagination: { limit: MULTI_SOURCE_MAX_ROWS_PER_SOURCE, offset: 0 } });
|
||||
});
|
||||
|
||||
it("does not count the time an operator spends on the danger confirmation", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sql = "UPDATE t_order SET shard_no = 999";
|
||||
const tab = { ...queryTab(), sql };
|
||||
const connection = sqlServerConnection();
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
vi.spyOn(queryStore, "executeTabSql").mockImplementation(async () => {
|
||||
tab.result = { columns: [], rows: [], affected_rows: 2, execution_time_ms: 1 };
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(queryStore, "getExecutionTab").mockReturnValue(tab);
|
||||
const history = vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
|
||||
let answer: ((confirmed: boolean) => void) | undefined;
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => tab as QueryTab | undefined),
|
||||
activeConnection: computed(() => connection),
|
||||
executableSql: computed(() => sql),
|
||||
activeOutputView,
|
||||
requestDangerConfirmation: () => new Promise<boolean>((resolve) => (answer = resolve)),
|
||||
});
|
||||
|
||||
const running = execution.executeTargetSql({ tab, connection, sql });
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
answer?.(true);
|
||||
const result = await running;
|
||||
|
||||
// The batch is parked on the prompt, so neither the target nor the history
|
||||
// row may report those nine seconds as execution time.
|
||||
expect(result.status).toBe("success");
|
||||
expect(result.durationMs).toBeLessThan(1_000);
|
||||
expect(history.mock.calls[0]?.[0]).toMatchObject({ execution_time_ms: expect.any(Number) });
|
||||
expect((history.mock.calls[0]?.[0] as { execution_time_ms: number }).execution_time_ms).toBeLessThan(1_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, reactive, ref, type ComputedRef, type Ref } from "vue";
|
||||
import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
export interface MultiDbExecutionItem {
|
||||
id: string;
|
||||
@@ -11,6 +12,8 @@ export interface MultiDbExecutionItem {
|
||||
durationMs?: number;
|
||||
transaction?: MultiDbManualTransaction;
|
||||
settling?: boolean;
|
||||
/** Result produced by this target, kept for the merged multi-source view. */
|
||||
result?: QueryResult;
|
||||
}
|
||||
|
||||
export interface MultiDbExecutionBatch {
|
||||
@@ -158,6 +161,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.result = result.result;
|
||||
item.transaction = result.transaction;
|
||||
} catch (error) {
|
||||
// One target is intentionally isolated from the queue. Adapter errors
|
||||
@@ -170,11 +174,11 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBatch(current: MultiDbExecutionBatch): Promise<void> {
|
||||
async function executeBatch(current: MultiDbExecutionBatch, items = current.items): Promise<void> {
|
||||
if (current.mode === "parallel") {
|
||||
await Promise.all(current.items.map((item) => executeItem(current, item)));
|
||||
await Promise.all(items.map((item) => executeItem(current, item)));
|
||||
} else {
|
||||
for (const item of current.items) {
|
||||
for (const item of items) {
|
||||
await executeItem(current, item);
|
||||
if (current.cancelRequested) break;
|
||||
}
|
||||
@@ -189,8 +193,17 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
current.durationMs = current.completedAt - current.startedAt;
|
||||
}
|
||||
|
||||
async function runBatch(current: MultiDbExecutionBatch, items = current.items): Promise<void> {
|
||||
activeRun = executeBatch(current, items);
|
||||
try {
|
||||
await activeRun;
|
||||
} finally {
|
||||
activeRun = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function start(sql: string, targets: readonly MultiDbExecutionTarget[], context: MultiDbExecutionContextOverrides = {}, mode: MultiDbExecutionMode = "serial"): Promise<MultiDbExecutionBatch | undefined> {
|
||||
if (disposed || isRunning.value || hasTransactions.value || !sql.trim() || targets.length === 0) return undefined;
|
||||
if (disposed || activeRun || 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 }));
|
||||
@@ -216,12 +229,25 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
batch.value = current;
|
||||
activeRun = executeBatch(current);
|
||||
await activeRun;
|
||||
activeRun = undefined;
|
||||
await runBatch(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
async function retry(itemId: string): Promise<void> {
|
||||
const current = batch.value;
|
||||
const item = current?.items.find((candidate) => candidate.id === itemId);
|
||||
if (disposed || activeRun || isRunning.value || !current || !item || item.transaction || item.settling || !["failed", "cancelled", "not_executed", "skipped", "rolled_back"].includes(item.status)) return;
|
||||
current.status = "running";
|
||||
current.cancelRequested = false;
|
||||
current.completedAt = undefined;
|
||||
current.durationMs = undefined;
|
||||
item.errorMessage = undefined;
|
||||
item.completedAt = undefined;
|
||||
item.durationMs = undefined;
|
||||
item.result = undefined;
|
||||
await runBatch(current, [item]);
|
||||
}
|
||||
|
||||
async function cancel(): Promise<void> {
|
||||
const current = batch.value;
|
||||
if (!current || current.status !== "running") return;
|
||||
@@ -250,7 +276,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
if (isRunning.value || hasTransactions.value) return;
|
||||
if (activeRun || isRunning.value || hasTransactions.value) return;
|
||||
batch.value = undefined;
|
||||
}
|
||||
|
||||
@@ -275,6 +301,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
dispose,
|
||||
cancelAndRollback,
|
||||
start,
|
||||
retry,
|
||||
cancel,
|
||||
reset,
|
||||
};
|
||||
|
||||
@@ -28,10 +28,11 @@ import { assessProductionSql } from "@/lib/database/productionSafety";
|
||||
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 { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
|
||||
import { MULTI_SOURCE_MAX_ROWS_PER_SOURCE } from "@/lib/query/multiSourceResult";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
|
||||
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
|
||||
@@ -66,6 +67,8 @@ interface TargetSqlExecutionInput {
|
||||
sourceOffset?: number;
|
||||
blockDangerousRedisCommands?: boolean;
|
||||
targetLabel?: string;
|
||||
/** Labels of every target in the batch, shown in the confirmation prompt. */
|
||||
batchTargetLabels?: string[];
|
||||
scopeId?: string;
|
||||
isCancellationRequested?: () => boolean;
|
||||
targetContext?: SqlExecutionTargetContext;
|
||||
@@ -79,6 +82,27 @@ export function stripSqlComments(sql: string): string {
|
||||
.replace(/#.*$/gm, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detached copy of a result for the merged multi-source view.
|
||||
*
|
||||
* The store releases a result payload by clearing its columns/rows *in place*
|
||||
* (see `releaseResultObjectPayload`) as soon as the run loses focus, and a
|
||||
* multi-db worker's tab is removed right after it finishes. Holding the live
|
||||
* result would therefore let the merged view lose earlier sources, so the
|
||||
* arrays it reads are detached here (cells themselves are never mutated).
|
||||
*/
|
||||
export function snapshotResultForMerge(result: QueryResult | undefined): QueryResult | undefined {
|
||||
if (!result) return undefined;
|
||||
return {
|
||||
...result,
|
||||
columns: [...result.columns],
|
||||
rows: result.rows.slice(),
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
local_column_filters: undefined,
|
||||
local_hidden_column_keys: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const ELASTICSEARCH_TRANSIENT_DELETE_PATHS = [/^\/_search\/scroll\/?$/i, /^\/_pit\/?$/i, /^\/_async_search\/[^/?]+\/?$/i];
|
||||
const ELASTICSEARCH_DESTRUCTIVE_POST_PATHS = [/(?:^|\/)_(?:delete_by_query|update_by_query|bulk)(?:\/|$)/i, /^\/_reindex(?:\/|$)/i, /^\/_aliases(?:\/|$)/i, /\/_restore(?:\/|$)/i];
|
||||
|
||||
@@ -466,6 +490,21 @@ export function useSqlExecution(deps: {
|
||||
async function executeTargetSql(input: TargetSqlExecutionInput): Promise<MultiDbTargetExecutionResult> {
|
||||
const { tab, connection, sql, sourceOffset, targetLabel } = input;
|
||||
const startedAt = Date.now();
|
||||
let confirmationWaitMs = 0;
|
||||
/**
|
||||
* A danger or production prompt parks the target until the operator answers.
|
||||
* Reading and typing that answer is not execution time, so it is excluded
|
||||
* from the duration this target reports.
|
||||
*/
|
||||
const waitForConfirmation = async <T>(prompt: () => Promise<T> | undefined): Promise<T | undefined> => {
|
||||
const waitStartedAt = Date.now();
|
||||
try {
|
||||
return await prompt();
|
||||
} finally {
|
||||
confirmationWaitMs += Date.now() - waitStartedAt;
|
||||
}
|
||||
};
|
||||
const elapsedMs = () => Math.max(0, Date.now() - startedAt - confirmationWaitMs);
|
||||
const executionTab = input.executionTarget
|
||||
? {
|
||||
...tab,
|
||||
@@ -477,7 +516,7 @@ export function useSqlExecution(deps: {
|
||||
: tab;
|
||||
const finish = (result: MultiDbTargetExecutionResult): MultiDbTargetExecutionResult => ({
|
||||
...result,
|
||||
durationMs: Date.now() - startedAt,
|
||||
durationMs: elapsedMs(),
|
||||
});
|
||||
const cancelRequested = () => input.isCancellationRequested?.() === true;
|
||||
const tabCancelRequested = (count: number) => (tab.cancelRequestCount ?? 0) !== count;
|
||||
@@ -509,15 +548,18 @@ export function useSqlExecution(deps: {
|
||||
return finish({ status: "skipped", errorMessage: t("redis.blockedCommand", { command: "Redis" }) });
|
||||
}
|
||||
if (highestSafety === "confirm") {
|
||||
const confirmed = await deps.requestDangerConfirmation?.({
|
||||
sql,
|
||||
kind: "redis",
|
||||
connectionName: connection.name,
|
||||
database: executionTab.database,
|
||||
targetLabel,
|
||||
databaseType: connection.db_type,
|
||||
scopeId: input.scopeId,
|
||||
});
|
||||
const confirmed = await waitForConfirmation(() =>
|
||||
deps.requestDangerConfirmation?.({
|
||||
sql,
|
||||
kind: "redis",
|
||||
connectionName: connection.name,
|
||||
database: executionTab.database,
|
||||
targetLabel,
|
||||
targets: input.batchTargetLabels,
|
||||
databaseType: connection.db_type,
|
||||
scopeId: input.scopeId,
|
||||
}),
|
||||
);
|
||||
if (cancelRequested()) return finish({ status: "cancelled" });
|
||||
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
|
||||
}
|
||||
@@ -525,28 +567,33 @@ export function useSqlExecution(deps: {
|
||||
|
||||
const productionAssessment = assessProductionSql(sql, connection, executionTab.database);
|
||||
if (productionAssessment.active && productionAssessment.isMutation) {
|
||||
const confirmed = await productionSafetyStore.requestConfirmation({
|
||||
sql,
|
||||
connectionName: connection.name,
|
||||
database: executionTab.database,
|
||||
productionDatabases: productionAssessment.databases,
|
||||
source: t("production.sourceMultiDbSql"),
|
||||
scopeId: input.scopeId,
|
||||
});
|
||||
const confirmed = await waitForConfirmation(() =>
|
||||
productionSafetyStore.requestConfirmation({
|
||||
sql,
|
||||
connectionName: connection.name,
|
||||
database: executionTab.database,
|
||||
productionDatabases: productionAssessment.databases,
|
||||
source: t("production.sourceMultiDbSql"),
|
||||
scopeId: input.scopeId,
|
||||
}),
|
||||
);
|
||||
if (cancelRequested()) return finish({ status: "cancelled" });
|
||||
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
|
||||
}
|
||||
|
||||
if (isDangerousSql(sql, connection.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
|
||||
const confirmed = await deps.requestDangerConfirmation?.({
|
||||
sql,
|
||||
kind: "sql",
|
||||
connectionName: connection.name,
|
||||
database: executionTab.database,
|
||||
targetLabel,
|
||||
databaseType: connection.db_type,
|
||||
scopeId: input.scopeId,
|
||||
});
|
||||
const confirmed = await waitForConfirmation(() =>
|
||||
deps.requestDangerConfirmation?.({
|
||||
sql,
|
||||
kind: "sql",
|
||||
connectionName: connection.name,
|
||||
database: executionTab.database,
|
||||
targetLabel,
|
||||
targets: input.batchTargetLabels,
|
||||
databaseType: connection.db_type,
|
||||
scopeId: input.scopeId,
|
||||
}),
|
||||
);
|
||||
if (cancelRequested()) return finish({ status: "cancelled" });
|
||||
if (!confirmed) return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
|
||||
}
|
||||
@@ -567,7 +614,7 @@ export function useSqlExecution(deps: {
|
||||
target: input.resultRun.target,
|
||||
title: input.resultRun.title,
|
||||
status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
durationMs: elapsedMs(),
|
||||
errorMessage,
|
||||
});
|
||||
};
|
||||
@@ -668,6 +715,10 @@ export function useSqlExecution(deps: {
|
||||
} else
|
||||
await queryStore.executeTabSql(executionTabId, sql, {
|
||||
resultBaseSql: sql,
|
||||
// A multi-database run is read to be inspected and exported as one
|
||||
// merged table, so every source fetches a real body of rows instead of
|
||||
// the editor's first page. The user's own result-row limit still wins.
|
||||
pagination: { limit: MULTI_SOURCE_MAX_ROWS_PER_SOURCE, offset: 0 },
|
||||
...(input.targetContext ? { targetContext: input.targetContext } : {}),
|
||||
...(sourceOffset !== undefined ? { sourceOffset } : {}),
|
||||
...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}),
|
||||
@@ -680,9 +731,13 @@ export function useSqlExecution(deps: {
|
||||
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;
|
||||
// The produced result travels back with the target status so the dialog can
|
||||
// union every target's rows into the merged multi-source view. Snapshot it
|
||||
// before recording the run: later cleanup releases the worker payload.
|
||||
const mergeResult = snapshotResultForMerge(latest.result);
|
||||
const resultStatus = success ? (input.manualTransaction ? "pending_commit" : "success") : "failed";
|
||||
recordedRunId = captureWorkerResult(resultStatus, errorMessage);
|
||||
const executionDuration = Date.now() - startedAt;
|
||||
const executionDuration = elapsedMs();
|
||||
const recordOutcome = async () => {
|
||||
await historyStore.add({
|
||||
connection_id: executionTab.connectionId,
|
||||
@@ -712,7 +767,7 @@ export function useSqlExecution(deps: {
|
||||
transaction.canCommit = true;
|
||||
retainedWorker = true;
|
||||
recordCommittedOutcome = recordOutcome;
|
||||
return finish({ status: "pending_commit", transaction });
|
||||
return finish({ status: "pending_commit", transaction, result: mergeResult });
|
||||
}
|
||||
await recordOutcome();
|
||||
if (input.manualTransaction) return await failedManualResult("failed", errorMessage);
|
||||
@@ -723,7 +778,9 @@ export function useSqlExecution(deps: {
|
||||
if (!workerId && deps.activeTab.value?.id === tab.id) {
|
||||
deps.activeOutputView.value = success && latest.result?.server_message === true ? "messages" : success && (latest.result?.columns.length || latest.results?.some((result) => result.columns.length)) ? "result" : "summary";
|
||||
}
|
||||
return finish(success ? { status: "success", errorMessage } : { status: "failed", errorMessage });
|
||||
// A target that ran inside a manual transaction keeps its own worker and
|
||||
// session alive, and reports the transaction the merged view settles.
|
||||
return finish(success ? { status: "success", errorMessage, result: mergeResult } : { status: "failed", errorMessage, result: mergeResult });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
captureWorkerResult("failed", errorMessage);
|
||||
|
||||
@@ -478,6 +478,7 @@ export default {
|
||||
switchToSerial: "Switch to serial",
|
||||
elapsed: "Elapsed: {duration}",
|
||||
pending: "Pending",
|
||||
awaitingConfirmation: "Waiting for confirmation",
|
||||
running: "Running",
|
||||
success: "Success",
|
||||
failed: "Failed",
|
||||
@@ -489,6 +490,45 @@ export default {
|
||||
dangerTitle: "Confirm target execution",
|
||||
dangerConfirm: "Execute on this target",
|
||||
resultTabPrefix: "Multi-db",
|
||||
mergeResults: "Merged result view",
|
||||
mergeResultsBack: "Back to progress",
|
||||
mergedSourceColumn: "Source",
|
||||
mergedSources: "{count} data sources · {rows} rows",
|
||||
mergedSourceRowCap: "Up to {count} rows per data source",
|
||||
mergedWriteSummary: "{targets} targets · {rows} rows affected",
|
||||
mergedSourceCapReached: "{count} data source(s) hit the per-source limit ({limit} rows); more rows may exist",
|
||||
mergedSummaryLabel: "SUM",
|
||||
mergedSummaryCellHint: "Sum of {count} numeric values",
|
||||
backgroundBatchDone: "{count} data source(s) finished; the merged view is ready",
|
||||
statusColumn: "Status",
|
||||
affectedRowsColumn: "Affected rows",
|
||||
durationColumn: "Elapsed",
|
||||
errorColumn: "Error",
|
||||
rerunTarget: "Re-run here",
|
||||
dangerTargets: "Data sources ({count})",
|
||||
dangerConfirmCodePrompt: "Type this code to confirm",
|
||||
dangerConfirmCodeCopy: "Copy code",
|
||||
dangerConfirmCodeOnce: "Asked once per batch; paste it if you copied it.",
|
||||
dangerConfirmCodePlaceholder: "Verification code",
|
||||
txnColumn: "Transaction",
|
||||
txnOpen: "Uncommitted",
|
||||
txnCommitted: "Committed",
|
||||
txnRolledBack: "Rolled back",
|
||||
txnOpenHint: "{count} target(s) not committed yet",
|
||||
commitAllTargets: "Commit all ({count})",
|
||||
rollbackAllTargets: "Roll back all ({count})",
|
||||
txnActionFailed: "Transaction action failed: {message}",
|
||||
elapsedColumn: "Elapsed: {duration}",
|
||||
executedAtColumn: "Executed at",
|
||||
executedAtChip: "Executed at: {time}",
|
||||
mergedRowLimit: "Showing the first {count} rows; totals cover all {rows} rows",
|
||||
mergedTruncated: "Merged result truncated (limit {count} rows)",
|
||||
mergedEmpty: "This batch produced no rows to merge.",
|
||||
mergedSheetName: "Merged result",
|
||||
exportCurrentPage: "Export current page",
|
||||
exportAllRows: "Export all (Excel)",
|
||||
exportedRows: "Exported {rows} rows",
|
||||
exportedRowsCapped: "Exported {rows} rows; {count} data source(s) hit the {limit}-row limit",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "File changed outside DBX",
|
||||
|
||||
@@ -470,6 +470,7 @@ export default withEnglishFallback({
|
||||
switchToSerial: "Cambiar a secuencial",
|
||||
elapsed: "Tiempo transcurrido: {duration}",
|
||||
pending: "Pendiente",
|
||||
awaitingConfirmation: "Esperando confirmación",
|
||||
running: "En ejecución",
|
||||
success: "Correcto",
|
||||
failed: "Fallido",
|
||||
@@ -481,6 +482,45 @@ export default withEnglishFallback({
|
||||
dangerTitle: "Confirmar ejecución de destinos",
|
||||
dangerConfirm: "Ejecutar en este destino",
|
||||
resultTabPrefix: "Varias bases de datos",
|
||||
mergeResults: "Vista de resultados combinados",
|
||||
mergeResultsBack: "Volver al progreso",
|
||||
mergedSourceColumn: "Origen",
|
||||
mergedSources: "{count} orígenes de datos · {rows} filas",
|
||||
mergedSourceRowCap: "Máximo {count} filas por origen de datos",
|
||||
mergedWriteSummary: "{targets} destinos · {rows} filas afectadas",
|
||||
mergedSourceCapReached: "{count} origen(es) alcanzaron el límite por origen ({limit} filas); puede haber más datos",
|
||||
mergedSummaryLabel: "SUMA",
|
||||
mergedSummaryCellHint: "Suma de {count} valores numéricos",
|
||||
backgroundBatchDone: "{count} origen(es) de datos terminaron; el resultado combinado está listo",
|
||||
statusColumn: "Estado",
|
||||
affectedRowsColumn: "Filas afectadas",
|
||||
durationColumn: "Tiempo",
|
||||
errorColumn: "Error",
|
||||
rerunTarget: "Reejecutar aquí",
|
||||
dangerTargets: "Orígenes de datos ({count})",
|
||||
dangerConfirmCodePrompt: "Escribe este código para confirmar",
|
||||
dangerConfirmCodeCopy: "Copiar código",
|
||||
dangerConfirmCodeOnce: "Se pide una vez por lote; puedes copiarlo y pegarlo.",
|
||||
dangerConfirmCodePlaceholder: "Código de verificación",
|
||||
txnColumn: "Transacción",
|
||||
txnOpen: "Sin confirmar",
|
||||
txnCommitted: "Confirmada",
|
||||
txnRolledBack: "Revertida",
|
||||
txnOpenHint: "{count} destino(s) aún sin confirmar",
|
||||
commitAllTargets: "Confirmar todo ({count})",
|
||||
rollbackAllTargets: "Revertir todo ({count})",
|
||||
txnActionFailed: "Falló la operación de transacción: {message}",
|
||||
elapsedColumn: "Tiempo: {duration}",
|
||||
executedAtColumn: "Ejecutado el",
|
||||
executedAtChip: "Ejecutado el: {time}",
|
||||
mergedRowLimit: "Mostrando las primeras {count} filas; los totales abarcan las {rows} filas",
|
||||
mergedTruncated: "Resultado combinado truncado (límite {count} filas)",
|
||||
mergedEmpty: "Esta ejecución no produjo filas que combinar.",
|
||||
mergedSheetName: "Resultado combinado",
|
||||
exportCurrentPage: "Exportar página actual",
|
||||
exportAllRows: "Exportar todo (Excel)",
|
||||
exportedRows: "Se exportaron {rows} filas",
|
||||
exportedRowsCapped: "Se exportaron {rows} filas; {count} origen(es) alcanzaron el límite de {limit} filas",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "El archivo cambió fuera de DBX",
|
||||
|
||||
@@ -468,6 +468,7 @@ export default withEnglishFallback({
|
||||
switchToSerial: "Passa a seriale",
|
||||
elapsed: "Tempo trascorso: {duration}",
|
||||
pending: "In attesa",
|
||||
awaitingConfirmation: "In attesa di conferma",
|
||||
running: "In esecuzione",
|
||||
success: "Riuscita",
|
||||
failed: "Non riuscita",
|
||||
@@ -479,6 +480,45 @@ export default withEnglishFallback({
|
||||
dangerTitle: "Conferma esecuzione destinazioni",
|
||||
dangerConfirm: "Esegui su questa destinazione",
|
||||
resultTabPrefix: "Multi-database",
|
||||
mergeResults: "Vista risultati uniti",
|
||||
mergeResultsBack: "Torna al progresso",
|
||||
mergedSourceColumn: "Origine",
|
||||
mergedSources: "{count} origini dati · {rows} righe",
|
||||
mergedSourceRowCap: "Massimo {count} righe per origine dati",
|
||||
mergedWriteSummary: "{targets} destinazioni · {rows} righe interessate",
|
||||
mergedSourceCapReached: "{count} origine/i ha/hanno raggiunto il limite per origine ({limit} righe); potrebbero esserci altri dati",
|
||||
mergedSummaryLabel: "SOMMA",
|
||||
mergedSummaryCellHint: "Somma di {count} valori numerici",
|
||||
backgroundBatchDone: "{count} origine/i dati ha/hanno terminato; il risultato unito è pronto",
|
||||
statusColumn: "Stato",
|
||||
affectedRowsColumn: "Righe interessate",
|
||||
durationColumn: "Tempo",
|
||||
errorColumn: "Errore",
|
||||
rerunTarget: "Riesegui qui",
|
||||
dangerTargets: "Origini dati ({count})",
|
||||
dangerConfirmCodePrompt: "Digita questo codice per confermare",
|
||||
dangerConfirmCodeCopy: "Copia codice",
|
||||
dangerConfirmCodeOnce: "Richiesto una volta per lotto; puoi copiarlo e incollarlo.",
|
||||
dangerConfirmCodePlaceholder: "Codice di verifica",
|
||||
txnColumn: "Transazione",
|
||||
txnOpen: "Non confermata",
|
||||
txnCommitted: "Confermata",
|
||||
txnRolledBack: "Annullata",
|
||||
txnOpenHint: "{count} destinazione/i non ancora confermate",
|
||||
commitAllTargets: "Conferma tutto ({count})",
|
||||
rollbackAllTargets: "Annulla tutto ({count})",
|
||||
txnActionFailed: "Operazione di transazione non riuscita: {message}",
|
||||
elapsedColumn: "Tempo: {duration}",
|
||||
executedAtColumn: "Eseguito il",
|
||||
executedAtChip: "Eseguito il: {time}",
|
||||
mergedRowLimit: "Mostrate le prime {count} righe; i totali coprono tutte le {rows} righe",
|
||||
mergedTruncated: "Risultato unito troncato (limite {count} righe)",
|
||||
mergedEmpty: "Questa esecuzione non ha prodotto righe da unire.",
|
||||
mergedSheetName: "Risultato unito",
|
||||
exportCurrentPage: "Esporta pagina corrente",
|
||||
exportAllRows: "Esporta tutto (Excel)",
|
||||
exportedRows: "Esportate {rows} righe",
|
||||
exportedRowsCapped: "Esportate {rows} righe; {count} origine/i ha/hanno raggiunto il limite di {limit} righe",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "File modificato esternamente a DBX",
|
||||
|
||||
@@ -469,6 +469,7 @@ export default withEnglishFallback({
|
||||
switchToSerial: "直列に切り替え",
|
||||
elapsed: "経過時間:{duration}",
|
||||
pending: "待機中",
|
||||
awaitingConfirmation: "確認待ち",
|
||||
running: "実行中",
|
||||
success: "成功",
|
||||
failed: "失敗",
|
||||
@@ -480,6 +481,45 @@ export default withEnglishFallback({
|
||||
dangerTitle: "ターゲットでの実行を確認",
|
||||
dangerConfirm: "このターゲットで実行",
|
||||
resultTabPrefix: "複数DB",
|
||||
mergeResults: "マージ結果ビュー",
|
||||
mergeResultsBack: "実行状況に戻る",
|
||||
mergedSourceColumn: "取得元",
|
||||
mergedSources: "{count} 個のデータソース · {rows} 行",
|
||||
mergedSourceRowCap: "1 つのデータソースにつき最大 {count} 行",
|
||||
mergedWriteSummary: "{targets} 個のターゲット · {rows} 行に影響",
|
||||
mergedSourceCapReached: "{count} 個のデータソースが単一ソース上限({limit} 行)に達しました。さらにデータがある可能性があります",
|
||||
mergedSummaryLabel: "合計",
|
||||
mergedSummaryCellHint: "{count} 個の数値の合計",
|
||||
backgroundBatchDone: "{count} 個のデータソースの実行が完了し、マージ結果を表示できます",
|
||||
statusColumn: "状態",
|
||||
affectedRowsColumn: "影響行数",
|
||||
durationColumn: "所要時間",
|
||||
errorColumn: "エラー",
|
||||
rerunTarget: "この対象だけ再実行",
|
||||
dangerTargets: "実行するデータソース({count})",
|
||||
dangerConfirmCodePrompt: "次の確認コードを入力してください",
|
||||
dangerConfirmCodeCopy: "コードをコピー",
|
||||
dangerConfirmCodeOnce: "確認はバッチごとに 1 回だけです。コピーして貼り付けできます。",
|
||||
dangerConfirmCodePlaceholder: "確認コード",
|
||||
txnColumn: "トランザクション",
|
||||
txnOpen: "未コミット",
|
||||
txnCommitted: "コミット済み",
|
||||
txnRolledBack: "ロールバック済み",
|
||||
txnOpenHint: "{count} 件の対象が未コミットです",
|
||||
commitAllTargets: "まとめてコミット({count})",
|
||||
rollbackAllTargets: "まとめてロールバック({count})",
|
||||
txnActionFailed: "トランザクション操作に失敗しました:{message}",
|
||||
elapsedColumn: "実行時間:{duration}",
|
||||
executedAtColumn: "実行日時",
|
||||
executedAtChip: "実行日時:{time}",
|
||||
mergedRowLimit: "先頭 {count} 行のみ表示中。合計は全 {rows} 行が対象です",
|
||||
mergedTruncated: "マージ結果は切り詰められました(上限 {count} 行)",
|
||||
mergedEmpty: "この実行にはマージできる行がありません。",
|
||||
mergedSheetName: "マージ結果",
|
||||
exportCurrentPage: "現在のページを書き出す",
|
||||
exportAllRows: "すべて書き出す(Excel)",
|
||||
exportedRows: "{rows} 行を書き出しました",
|
||||
exportedRowsCapped: "{rows} 行を書き出しました。{count} 個のデータソースが {limit} 行の単一ソース上限に達しています",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "ファイルがDBXの外部で変更されました",
|
||||
|
||||
@@ -476,6 +476,7 @@ export default withEnglishFallback({
|
||||
switchToSerial: "직렬로 전환",
|
||||
elapsed: "경과 시간: {duration}",
|
||||
pending: "대기 중",
|
||||
awaitingConfirmation: "확인 대기 중",
|
||||
running: "실행 중",
|
||||
success: "성공",
|
||||
failed: "실패",
|
||||
@@ -487,6 +488,45 @@ export default withEnglishFallback({
|
||||
dangerTitle: "대상 실행 확인",
|
||||
dangerConfirm: "이 대상에서 실행",
|
||||
resultTabPrefix: "다중 DB",
|
||||
mergeResults: "병합 결과 보기",
|
||||
mergeResultsBack: "실행 진행으로 돌아가기",
|
||||
mergedSourceColumn: "원본",
|
||||
mergedSources: "데이터 원본 {count}개 · {rows}행",
|
||||
mergedSourceRowCap: "데이터 원본당 최대 {count}행",
|
||||
mergedWriteSummary: "대상 {targets}개 · {rows}행 영향",
|
||||
mergedSourceCapReached: "{count}개 데이터 원본이 단일 원본 한도({limit}행)에 도달했습니다. 더 많은 데이터가 있을 수 있습니다",
|
||||
mergedSummaryLabel: "합계",
|
||||
mergedSummaryCellHint: "숫자 {count}개의 합계",
|
||||
backgroundBatchDone: "데이터 원본 {count}개 실행이 완료되어 병합 결과를 볼 수 있습니다",
|
||||
statusColumn: "상태",
|
||||
affectedRowsColumn: "영향 행 수",
|
||||
durationColumn: "소요 시간",
|
||||
errorColumn: "오류",
|
||||
rerunTarget: "이 대상만 다시 실행",
|
||||
dangerTargets: "실행할 데이터 원본({count})",
|
||||
dangerConfirmCodePrompt: "아래 확인 코드를 입력하세요",
|
||||
dangerConfirmCodeCopy: "코드 복사",
|
||||
dangerConfirmCodeOnce: "배치당 한 번만 확인합니다. 복사해서 붙여넣을 수 있습니다.",
|
||||
dangerConfirmCodePlaceholder: "확인 코드",
|
||||
txnColumn: "트랜잭션",
|
||||
txnOpen: "미커밋",
|
||||
txnCommitted: "커밋됨",
|
||||
txnRolledBack: "롤백됨",
|
||||
txnOpenHint: "{count}개 대상이 아직 커밋되지 않았습니다",
|
||||
commitAllTargets: "모두 커밋({count})",
|
||||
rollbackAllTargets: "모두 롤백({count})",
|
||||
txnActionFailed: "트랜잭션 작업 실패: {message}",
|
||||
elapsedColumn: "실행 시간: {duration}",
|
||||
executedAtColumn: "실행 시각",
|
||||
executedAtChip: "실행 시각: {time}",
|
||||
mergedRowLimit: "처음 {count}행만 표시하며 합계는 전체 {rows}행 기준입니다",
|
||||
mergedTruncated: "병합 결과가 잘렸습니다(한도 {count}행)",
|
||||
mergedEmpty: "이번 실행에는 병합할 결과 행이 없습니다.",
|
||||
mergedSheetName: "병합 결과",
|
||||
exportCurrentPage: "현재 페이지 내보내기",
|
||||
exportAllRows: "전체 내보내기(Excel)",
|
||||
exportedRows: "{rows}행을 내보냈습니다",
|
||||
exportedRowsCapped: "{rows}행을 내보냈습니다. {count}개 데이터 원본이 {limit}행 단일 원본 한도에 도달했습니다",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "DBX 외부에서 파일이 변경됨",
|
||||
|
||||
@@ -469,6 +469,7 @@ export default withEnglishFallback({
|
||||
switchToSerial: "Mudar para serial",
|
||||
elapsed: "Tempo decorrido: {duration}",
|
||||
pending: "Pendente",
|
||||
awaitingConfirmation: "Aguardando confirmação",
|
||||
running: "Em execução",
|
||||
success: "Sucesso",
|
||||
failed: "Falha",
|
||||
@@ -480,6 +481,45 @@ export default withEnglishFallback({
|
||||
dangerTitle: "Confirmar execução dos destinos",
|
||||
dangerConfirm: "Executar neste destino",
|
||||
resultTabPrefix: "Vários bancos",
|
||||
mergeResults: "Visão de resultados combinados",
|
||||
mergeResultsBack: "Voltar ao progresso",
|
||||
mergedSourceColumn: "Origem",
|
||||
mergedSources: "{count} origens de dados · {rows} linhas",
|
||||
mergedSourceRowCap: "Máximo de {count} linhas por origem de dados",
|
||||
mergedWriteSummary: "{targets} destinos · {rows} linhas afetadas",
|
||||
mergedSourceCapReached: "{count} origem(ns) atingiram o limite por origem ({limit} linhas); pode haver mais dados",
|
||||
mergedSummaryLabel: "SOMA",
|
||||
mergedSummaryCellHint: "Soma de {count} valores numéricos",
|
||||
backgroundBatchDone: "{count} origem(ns) de dados concluíram; o resultado combinado está pronto",
|
||||
statusColumn: "Status",
|
||||
affectedRowsColumn: "Linhas afetadas",
|
||||
durationColumn: "Tempo",
|
||||
errorColumn: "Erro",
|
||||
rerunTarget: "Executar novamente aqui",
|
||||
dangerTargets: "Origens de dados ({count})",
|
||||
dangerConfirmCodePrompt: "Digite este código para confirmar",
|
||||
dangerConfirmCodeCopy: "Copiar código",
|
||||
dangerConfirmCodeOnce: "Pedido uma vez por lote; você pode copiar e colar.",
|
||||
dangerConfirmCodePlaceholder: "Código de verificação",
|
||||
txnColumn: "Transação",
|
||||
txnOpen: "Não confirmada",
|
||||
txnCommitted: "Confirmada",
|
||||
txnRolledBack: "Revertida",
|
||||
txnOpenHint: "{count} destino(s) ainda não confirmados",
|
||||
commitAllTargets: "Confirmar tudo ({count})",
|
||||
rollbackAllTargets: "Reverter tudo ({count})",
|
||||
txnActionFailed: "Falha na operação de transação: {message}",
|
||||
elapsedColumn: "Tempo: {duration}",
|
||||
executedAtColumn: "Executado em",
|
||||
executedAtChip: "Executado em: {time}",
|
||||
mergedRowLimit: "Mostrando as primeiras {count} linhas; os totais cobrem todas as {rows} linhas",
|
||||
mergedTruncated: "Resultado combinado truncado (limite {count} linhas)",
|
||||
mergedEmpty: "Esta execução não produziu linhas para combinar.",
|
||||
mergedSheetName: "Resultado combinado",
|
||||
exportCurrentPage: "Exportar página atual",
|
||||
exportAllRows: "Exportar tudo (Excel)",
|
||||
exportedRows: "{rows} linhas exportadas",
|
||||
exportedRowsCapped: "{rows} linhas exportadas; {count} origem(ns) atingiram o limite de {limit} linhas",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "Arquivo alterado fora do DBX",
|
||||
|
||||
@@ -394,6 +394,7 @@ export default withEnglishFallback({
|
||||
switchToSerial: "切换为串行",
|
||||
elapsed: "耗时:{duration}",
|
||||
pending: "等待中",
|
||||
awaitingConfirmation: "等待确认",
|
||||
running: "执行中",
|
||||
success: "成功",
|
||||
failed: "失败",
|
||||
@@ -405,6 +406,45 @@ export default withEnglishFallback({
|
||||
dangerTitle: "确认目标执行",
|
||||
dangerConfirm: "在此目标上执行",
|
||||
resultTabPrefix: "多库",
|
||||
mergeResults: "合并结果视图",
|
||||
mergeResultsBack: "返回执行进度",
|
||||
mergedSourceColumn: "来源",
|
||||
mergedSources: "{count} 个数据源 · {rows} 行",
|
||||
mergedSourceRowCap: "单个数据源最多读取 {count} 行",
|
||||
mergedWriteSummary: "{targets} 个目标 · 影响 {rows} 行",
|
||||
mergedSourceCapReached: "有 {count} 个数据源已达单源上限({limit} 行),可能还有更多数据",
|
||||
mergedSummaryLabel: "合计",
|
||||
mergedSummaryCellHint: "{count} 个数字之和",
|
||||
backgroundBatchDone: "{count} 个数据源执行完成,合并结果已就绪",
|
||||
statusColumn: "状态",
|
||||
affectedRowsColumn: "影响行数",
|
||||
durationColumn: "耗时",
|
||||
errorColumn: "错误",
|
||||
rerunTarget: "单独重跑",
|
||||
dangerTargets: "将执行的数据源({count})",
|
||||
dangerConfirmCodePrompt: "输入下面的确认码",
|
||||
dangerConfirmCodeCopy: "复制确认码",
|
||||
dangerConfirmCodeOnce: "整批只需确认一次;复制后可直接粘贴。",
|
||||
dangerConfirmCodePlaceholder: "确认码",
|
||||
txnColumn: "事务",
|
||||
txnOpen: "未提交",
|
||||
txnCommitted: "已提交",
|
||||
txnRolledBack: "已回滚",
|
||||
txnOpenHint: "有 {count} 个目标尚未提交",
|
||||
commitAllTargets: "批量提交({count})",
|
||||
rollbackAllTargets: "批量回滚({count})",
|
||||
txnActionFailed: "事务操作失败:{message}",
|
||||
elapsedColumn: "执行耗时:{duration}",
|
||||
executedAtColumn: "执行时间",
|
||||
executedAtChip: "执行时间:{time}",
|
||||
mergedRowLimit: "仅显示前 {count} 行,合计基于全部 {rows} 行",
|
||||
mergedTruncated: "合并结果已截断(上限 {count} 行)",
|
||||
mergedEmpty: "本次执行没有可合并的结果行。",
|
||||
mergedSheetName: "合并结果",
|
||||
exportCurrentPage: "导出当前页",
|
||||
exportAllRows: "导出全部(Excel)",
|
||||
exportedRows: "已导出 {rows} 行",
|
||||
exportedRowsCapped: "已导出 {rows} 行;{count} 个数据源达到了 {limit} 行的单源上限",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "文件已在外部发生修改",
|
||||
|
||||
@@ -472,6 +472,7 @@ export default withEnglishFallback({
|
||||
switchToSerial: "切換為串行",
|
||||
elapsed: "耗時:{duration}",
|
||||
pending: "等待中",
|
||||
awaitingConfirmation: "等待確認",
|
||||
running: "執行中",
|
||||
success: "成功",
|
||||
failed: "失敗",
|
||||
@@ -483,6 +484,45 @@ export default withEnglishFallback({
|
||||
dangerTitle: "確認執行目標",
|
||||
dangerConfirm: "在此目標上執行",
|
||||
resultTabPrefix: "多資料庫",
|
||||
mergeResults: "合併結果檢視",
|
||||
mergeResultsBack: "返回執行進度",
|
||||
mergedSourceColumn: "來源",
|
||||
mergedSources: "{count} 個資料來源 · {rows} 列",
|
||||
mergedSourceRowCap: "單一資料來源最多讀取 {count} 列",
|
||||
mergedWriteSummary: "{targets} 個目標 · 影響 {rows} 筆",
|
||||
mergedSourceCapReached: "有 {count} 個資料來源已達單源上限({limit} 列),可能還有更多資料",
|
||||
mergedSummaryLabel: "合計",
|
||||
mergedSummaryCellHint: "{count} 個數字之和",
|
||||
backgroundBatchDone: "{count} 個資料來源執行完成,合併結果已就緒",
|
||||
statusColumn: "狀態",
|
||||
affectedRowsColumn: "影響列數",
|
||||
durationColumn: "耗時",
|
||||
errorColumn: "錯誤",
|
||||
rerunTarget: "單獨重跑",
|
||||
dangerTargets: "將執行的資料來源({count})",
|
||||
dangerConfirmCodePrompt: "輸入下面的確認碼",
|
||||
dangerConfirmCodeCopy: "複製確認碼",
|
||||
dangerConfirmCodeOnce: "整批只需確認一次;複製後可直接貼上。",
|
||||
dangerConfirmCodePlaceholder: "確認碼",
|
||||
txnColumn: "交易",
|
||||
txnOpen: "未提交",
|
||||
txnCommitted: "已提交",
|
||||
txnRolledBack: "已回復",
|
||||
txnOpenHint: "有 {count} 個目標尚未提交",
|
||||
commitAllTargets: "批次提交({count})",
|
||||
rollbackAllTargets: "批次回復({count})",
|
||||
txnActionFailed: "交易操作失敗:{message}",
|
||||
elapsedColumn: "執行耗時:{duration}",
|
||||
executedAtColumn: "執行時間",
|
||||
executedAtChip: "執行時間:{time}",
|
||||
mergedRowLimit: "僅顯示前 {count} 列,合計涵蓋全部 {rows} 列",
|
||||
mergedTruncated: "合併結果已截斷(上限 {count} 列)",
|
||||
mergedEmpty: "本次執行沒有可合併的結果列。",
|
||||
mergedSheetName: "合併結果",
|
||||
exportCurrentPage: "匯出目前頁面",
|
||||
exportAllRows: "匯出全部(Excel)",
|
||||
exportedRows: "已匯出 {rows} 列",
|
||||
exportedRowsCapped: "已匯出 {rows} 列;{count} 個資料來源達到 {limit} 列的單源上限",
|
||||
},
|
||||
externalSqlFile: {
|
||||
modifiedTitle: "檔案已在 DBX 外部變更",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EXECUTION_CONFIRM_CODE_LENGTH, createExecutionConfirmCode, matchesExecutionConfirmCode } from "@/lib/query/executionConfirmCode";
|
||||
|
||||
describe("executionConfirmCode", () => {
|
||||
it("generates a code of the documented length from an unambiguous alphabet", () => {
|
||||
for (let run = 0; run < 50; run += 1) {
|
||||
const code = createExecutionConfirmCode();
|
||||
expect(code).toHaveLength(EXECUTION_CONFIRM_CODE_LENGTH);
|
||||
// No 0/O/1/I look-alikes: the operator has to retype it from the screen.
|
||||
expect(code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for a given random source", () => {
|
||||
const sequence = () => {
|
||||
const values = [0, 0.5, 0.999, 0.25, 0.75, 0.1];
|
||||
let index = 0;
|
||||
return () => values[index++ % values.length]!;
|
||||
};
|
||||
expect(createExecutionConfirmCode(sequence())).toBe(createExecutionConfirmCode(sequence()));
|
||||
expect(createExecutionConfirmCode(() => 0)).toBe("AAAAAA");
|
||||
});
|
||||
|
||||
it("tolerates case and surrounding spaces when matching", () => {
|
||||
expect(matchesExecutionConfirmCode("ab12cd", "AB12CD")).toBe(true);
|
||||
expect(matchesExecutionConfirmCode(" AB12CD ", "ab12cd")).toBe(true);
|
||||
expect(matchesExecutionConfirmCode("", "AB12CD")).toBe(false);
|
||||
expect(matchesExecutionConfirmCode("AB12CE", "AB12CD")).toBe(false);
|
||||
});
|
||||
|
||||
it("never matches an empty code", () => {
|
||||
expect(matchesExecutionConfirmCode("", "")).toBe(false);
|
||||
expect(matchesExecutionConfirmCode("anything", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MULTI_SOURCE_MERGE_MAX_ROWS, isNumericSummaryCellValue, mergeMultiSourceResults, summarizeNumericColumns } from "@/lib/query/multiSourceResult";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function result(columns: string[], rows: (string | number | boolean | null)[][], overrides: Partial<QueryResult> = {}): QueryResult {
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
affected_rows: rows.length,
|
||||
execution_time_ms: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergeMultiSourceResults", () => {
|
||||
it("unions columns by name and injects the source column first", () => {
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "conn-a / sales", result: result(["id", "amount"], [[1, 10]]) },
|
||||
{ key: "b", label: "conn-b / sales", result: result(["amount", "region"], [[20, "north"]]) },
|
||||
],
|
||||
{ sourceColumnLabel: "Source" },
|
||||
);
|
||||
|
||||
expect(merged.columns).toEqual(["Source", "id", "amount", "region"]);
|
||||
expect(merged.rows).toEqual([
|
||||
["conn-a / sales", 1, 10, null],
|
||||
["conn-b / sales", null, 20, "north"],
|
||||
]);
|
||||
expect(merged.rowCount).toBe(2);
|
||||
expect(merged.truncated).toBe(false);
|
||||
expect(merged.skippedLabels).toEqual([]);
|
||||
expect(merged.sources).toEqual([
|
||||
{ key: "a", label: "conn-a / sales", rowCount: 1, columnCount: 2 },
|
||||
{ key: "b", label: "conn-b / sales", rowCount: 1, columnCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps duplicate column names of one source as separate value streams", () => {
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "a", result: result(["id", "id"], [[1, 2]]) },
|
||||
{ key: "b", label: "b", result: result(["id"], [[9]]) },
|
||||
],
|
||||
{ sourceColumnLabel: "Source" },
|
||||
);
|
||||
|
||||
expect(merged.columns).toEqual(["Source", "id", "id"]);
|
||||
expect(merged.rows).toEqual([
|
||||
["a", 1, 2],
|
||||
["b", 9, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes hidden middle columns without shifting source values or numeric summaries", () => {
|
||||
const first = result(["name", "__DBX_PK_0", "amount", "region"], [["Alice", 17, 10, "north"]], { hidden_column_indexes: [1] });
|
||||
const second = result(["amount", "name", "__DBX_PK_0"], [[20, "Bob", 23]], { hidden_column_indexes: [2] });
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "a", result: first },
|
||||
{ key: "b", label: "b", result: second },
|
||||
],
|
||||
{ sourceColumnLabel: "Source" },
|
||||
);
|
||||
|
||||
expect(merged.columns).toEqual(["Source", "name", "amount", "region"]);
|
||||
expect(merged.rows).toEqual([
|
||||
["a", "Alice", 10, "north"],
|
||||
["b", "Bob", 20, null],
|
||||
]);
|
||||
expect(merged.summaries).toEqual([{ columnIndex: 2, column: "amount", sum: 30, count: 2 }]);
|
||||
expect(merged.sources.map((source) => source.columnCount)).toEqual([3, 2]);
|
||||
expect(first.columns).toEqual(["name", "__DBX_PK_0", "amount", "region"]);
|
||||
expect(first.rows).toEqual([["Alice", 17, 10, "north"]]);
|
||||
});
|
||||
|
||||
it("matches visible duplicate names and keeps unnamed columns at their source ordinals", () => {
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "a", result: result(["id", "id", "id", ""], [[99, 1, 2, 3]], { hidden_column_indexes: [0] }) },
|
||||
{ key: "b", label: "b", result: result(["id", "id", "__DBX_PK_0", ""], [[4, 5, 88, 6]], { hidden_column_indexes: [2] }) },
|
||||
],
|
||||
{ sourceColumnLabel: "Source" },
|
||||
);
|
||||
|
||||
expect(merged.columns).toEqual(["Source", "id", "id", "column_4"]);
|
||||
expect(merged.rows).toEqual([
|
||||
["a", 1, 2, 3],
|
||||
["b", 4, 5, 6],
|
||||
]);
|
||||
expect(merged.summaries.map((summary) => summary.sum)).toEqual([5, 7, 9]);
|
||||
});
|
||||
|
||||
it("skips sources without a tabular payload instead of failing", () => {
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "empty", result: result([], []) },
|
||||
{ key: "b", label: "failed", result: result(["Error"], [["boom"]], { execution_error: true }) },
|
||||
{ key: "c", label: "missing", result: undefined },
|
||||
{ key: "d", label: "ok", result: result(["id"], [[7]]) },
|
||||
],
|
||||
{ sourceColumnLabel: "Source" },
|
||||
);
|
||||
|
||||
expect(merged.sources.map((source) => source.label)).toEqual(["ok"]);
|
||||
expect(merged.skippedLabels).toEqual(["empty", "failed", "missing"]);
|
||||
expect(merged.rows).toEqual([["ok", 7]]);
|
||||
});
|
||||
|
||||
it("caps merged rows and reports truncation", () => {
|
||||
const rows = Array.from({ length: 5 }, (_, index) => [index]);
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "a", result: result(["n"], rows) },
|
||||
{ key: "b", label: "b", result: result(["n"], rows) },
|
||||
],
|
||||
{ sourceColumnLabel: "Source", maxRows: 4 },
|
||||
);
|
||||
|
||||
expect(merged.rowCount).toBe(4);
|
||||
expect(merged.truncated).toBe(true);
|
||||
expect(merged.rows.map((row) => row[0])).toEqual(["a", "a", "a", "a"]);
|
||||
});
|
||||
|
||||
it("names unnamed columns positionally and tolerates ragged rows", () => {
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "a", result: result(["", "b"], [[1]]) },
|
||||
{ key: "b", label: "b", result: result(["b"], [[2, 3, 4]]) },
|
||||
],
|
||||
{ sourceColumnLabel: "Source" },
|
||||
);
|
||||
|
||||
expect(merged.columns).toEqual(["Source", "column_1", "b"]);
|
||||
expect(merged.rows).toEqual([
|
||||
["a", 1, null],
|
||||
["b", null, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("summarizes every all-numeric column of the merged table and never the source column", () => {
|
||||
const merged = mergeMultiSourceResults(
|
||||
[
|
||||
{ key: "a", label: "conn-a", result: result(["id", "amount", "label"], [[1, 10, "x"]]) },
|
||||
{ key: "b", label: "conn-b", result: result(["id", "amount", "label"], [[2, 32.5, "y"]]) },
|
||||
],
|
||||
{ sourceColumnLabel: "Source" },
|
||||
);
|
||||
|
||||
expect(merged.summaries).toEqual([
|
||||
{ columnIndex: 1, column: "id", sum: 3, count: 2 },
|
||||
{ columnIndex: 2, column: "amount", sum: 42.5, count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the documented row cap by default", () => {
|
||||
expect(MULTI_SOURCE_MERGE_MAX_ROWS).toBe(50_000);
|
||||
});
|
||||
|
||||
it("handles no usable input with an empty table", () => {
|
||||
const merged = mergeMultiSourceResults([{ key: "a", label: "a", result: undefined }], { sourceColumnLabel: "Source" });
|
||||
expect(merged.columns).toEqual(["Source"]);
|
||||
expect(merged.rows).toEqual([]);
|
||||
expect(merged.sources).toEqual([]);
|
||||
expect(merged.summaries).toEqual([]);
|
||||
expect(merged.skippedLabels).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeNumericColumns", () => {
|
||||
it("classifies numeric cells and rejects everything else", () => {
|
||||
expect(isNumericSummaryCellValue(1)).toBe(true);
|
||||
expect(isNumericSummaryCellValue(" 3.5 ")).toBe(true);
|
||||
expect(isNumericSummaryCellValue("")).toBe(false);
|
||||
expect(isNumericSummaryCellValue("12abc")).toBe(false);
|
||||
expect(isNumericSummaryCellValue("2026-07-24")).toBe(false);
|
||||
expect(isNumericSummaryCellValue(true)).toBe(false);
|
||||
expect(isNumericSummaryCellValue(null)).toBe(false);
|
||||
expect(isNumericSummaryCellValue(Number.NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores empty cells and drops a column once one value is not numeric", () => {
|
||||
expect(
|
||||
summarizeNumericColumns(
|
||||
["a", "b"],
|
||||
[
|
||||
[1, null],
|
||||
[null, "n/a"],
|
||||
["", 3],
|
||||
],
|
||||
),
|
||||
).toEqual([{ columnIndex: 0, column: "a", sum: 1, count: 1 }]);
|
||||
});
|
||||
|
||||
it("never sums boolean or all-empty columns", () => {
|
||||
expect(summarizeNumericColumns(["flag"], [[true], [false]])).toEqual([]);
|
||||
expect(summarizeNumericColumns(["a"], [[null], [null]])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Confirmation code shown in the "confirm target execution" prompt of a
|
||||
* multi-database batch: the operator has to type it back before the batch is
|
||||
* allowed to run, so a mis-click cannot fire the same statement at every
|
||||
* connection.
|
||||
*/
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
export const EXECUTION_CONFIRM_CODE_LENGTH = 6;
|
||||
|
||||
export function createExecutionConfirmCode(random: () => number = Math.random, length = EXECUTION_CONFIRM_CODE_LENGTH): string {
|
||||
const size = Math.max(1, Math.trunc(length));
|
||||
let code = "";
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
const value = random();
|
||||
const normalized = Number.isFinite(value) ? Math.min(Math.max(value, 0), 0.999_999) : 0;
|
||||
code += CODE_ALPHABET[Math.floor(normalized * CODE_ALPHABET.length)] ?? CODE_ALPHABET[0];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Typed input matches the displayed code, ignoring case and surrounding spaces. */
|
||||
export function matchesExecutionConfirmCode(input: string, code: string): boolean {
|
||||
if (!code) return false;
|
||||
return input.trim().toUpperCase() === code.trim().toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
/**
|
||||
* Rows read from a single target of a multi-database run. The merged view and
|
||||
* its Excel export need the query's body rather than just the editor's first
|
||||
* page, so every source is fetched up to this cap; the user's global result-row
|
||||
* limit still applies on top of it.
|
||||
*/
|
||||
export const MULTI_SOURCE_MAX_ROWS_PER_SOURCE = 10_000;
|
||||
|
||||
/**
|
||||
* Upper bound on merged rows. The merged view is a quick inspection surface, so
|
||||
* a fan-out over many connections must not be able to build an unbounded table;
|
||||
* hitting the cap is reported instead of silently dropping rows.
|
||||
*/
|
||||
export const MULTI_SOURCE_MERGE_MAX_ROWS = 50_000;
|
||||
|
||||
export type MultiSourceCellValue = string | number | boolean | null;
|
||||
|
||||
export interface MultiSourceResultInput {
|
||||
/** Stable identity of the contributing result (used as the row's origin key). */
|
||||
key: string;
|
||||
/** Human label written into the injected source column for every row. */
|
||||
label: string;
|
||||
result: QueryResult | undefined;
|
||||
}
|
||||
|
||||
export interface MultiSourceMergeOptions {
|
||||
/** Header of the injected first column; localized by the caller. */
|
||||
sourceColumnLabel: string;
|
||||
maxRows?: number;
|
||||
}
|
||||
|
||||
export interface MultiSourceColumnSummary {
|
||||
columnIndex: number;
|
||||
column: string;
|
||||
sum: number;
|
||||
/** Number of contributing values; empty cells never count. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MultiSourceMergeSource {
|
||||
key: string;
|
||||
label: string;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
export interface MultiSourceMergeOutput {
|
||||
columns: string[];
|
||||
rows: MultiSourceCellValue[][];
|
||||
/** One entry per all-numeric column, aligned by column ordinal. */
|
||||
summaries: MultiSourceColumnSummary[];
|
||||
sources: MultiSourceMergeSource[];
|
||||
rowCount: number;
|
||||
truncated: boolean;
|
||||
/** Labels of inputs that carried no tabular payload and were left out. */
|
||||
skippedLabels: string[];
|
||||
}
|
||||
|
||||
interface MergePlan {
|
||||
input: MultiSourceResultInput;
|
||||
result: QueryResult;
|
||||
/** Merged column ordinal for each source column ordinal. */
|
||||
targetIndexes: Map<number, number>;
|
||||
}
|
||||
|
||||
function usableInput(input: MultiSourceResultInput): boolean {
|
||||
if (!input.result) return false;
|
||||
if (input.result.execution_error === true) return false;
|
||||
return (input.result.columns?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function mergedColumnName(column: string | undefined, sourceIndex: number): string {
|
||||
const name = typeof column === "string" ? column.trim() : "";
|
||||
return name === "" ? `column_${sourceIndex + 1}` : name;
|
||||
}
|
||||
|
||||
function isEmptyCell(value: MultiSourceCellValue | undefined): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
return typeof value === "string" && value.trim() === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell counts as numeric when it is a finite number or a non-empty string that
|
||||
* parses to one (drivers return DECIMAL/BIGINT as strings). Booleans are not
|
||||
* numeric, so a flag column never turns into a total.
|
||||
*/
|
||||
export function isNumericSummaryCellValue(value: MultiSourceCellValue | undefined): boolean {
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed !== "" && Number.isFinite(Number(trimmed));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple result-level aggregates: sums every column whose non-empty cells are
|
||||
* all numeric. A single non-numeric cell disqualifies the whole column, and a
|
||||
* column without values produces no total.
|
||||
*/
|
||||
export function summarizeNumericColumns(columns: readonly string[], rows: readonly (readonly MultiSourceCellValue[])[], options: { skipColumnIndexes?: readonly number[] } = {}): MultiSourceColumnSummary[] {
|
||||
const skipped = new Set(options.skipColumnIndexes ?? []);
|
||||
const sums: number[] = Array.from({ length: columns.length }, () => 0);
|
||||
const counts: number[] = Array.from({ length: columns.length }, () => 0);
|
||||
const stillNumeric: boolean[] = Array.from({ length: columns.length }, () => true);
|
||||
|
||||
for (const row of rows) {
|
||||
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
|
||||
if (skipped.has(columnIndex) || !stillNumeric[columnIndex]) continue;
|
||||
const value = row[columnIndex];
|
||||
if (isEmptyCell(value)) continue;
|
||||
if (!isNumericSummaryCellValue(value)) {
|
||||
stillNumeric[columnIndex] = false;
|
||||
continue;
|
||||
}
|
||||
sums[columnIndex] += typeof value === "number" ? value : Number(value);
|
||||
counts[columnIndex] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const summaries: MultiSourceColumnSummary[] = [];
|
||||
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
|
||||
if (skipped.has(columnIndex) || !stillNumeric[columnIndex] || counts[columnIndex] === 0) continue;
|
||||
summaries.push({ columnIndex, column: columns[columnIndex] ?? "", sum: sums[columnIndex]!, count: counts[columnIndex]! });
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the results of one query executed against several connections /
|
||||
* databases into a single table.
|
||||
*
|
||||
* Columns are aligned by name (union, first-seen order) and every row keeps a
|
||||
* leading provenance column naming the source it came from. A column a source
|
||||
* does not have is padded with null. Duplicate column names inside one result
|
||||
* keep their own value streams by matching the Nth occurrence of a name.
|
||||
*/
|
||||
export function mergeMultiSourceResults(inputs: readonly MultiSourceResultInput[], options: MultiSourceMergeOptions): MultiSourceMergeOutput {
|
||||
const maxRows = Math.max(1, Math.trunc(options.maxRows ?? MULTI_SOURCE_MERGE_MAX_ROWS));
|
||||
const columns: string[] = [options.sourceColumnLabel];
|
||||
// A merged column is identified by (name, occurrence-in-source) so duplicate
|
||||
// names in one result still keep both of their value streams.
|
||||
const indexByLayoutKey = new Map<string, number>();
|
||||
const plans: MergePlan[] = [];
|
||||
const skippedLabels: string[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (!usableInput(input)) {
|
||||
skippedLabels.push(input.label);
|
||||
continue;
|
||||
}
|
||||
const result = input.result!;
|
||||
const occurrences = new Map<string, number>();
|
||||
const targetIndexes = new Map<number, number>();
|
||||
const hiddenColumnIndexes = new Set(result.hidden_column_indexes ?? []);
|
||||
result.columns.forEach((column, sourceIndex) => {
|
||||
if (hiddenColumnIndexes.has(sourceIndex)) return;
|
||||
const name = mergedColumnName(column, sourceIndex);
|
||||
const occurrence = occurrences.get(name) ?? 0;
|
||||
occurrences.set(name, occurrence + 1);
|
||||
const layoutKey = `${name}\u0000${occurrence}`;
|
||||
let targetIndex = indexByLayoutKey.get(layoutKey);
|
||||
if (targetIndex === undefined) {
|
||||
targetIndex = columns.length;
|
||||
indexByLayoutKey.set(layoutKey, targetIndex);
|
||||
columns.push(name);
|
||||
}
|
||||
targetIndexes.set(sourceIndex, targetIndex);
|
||||
});
|
||||
plans.push({ input, result, targetIndexes });
|
||||
}
|
||||
|
||||
const rows: MultiSourceCellValue[][] = [];
|
||||
let truncated = false;
|
||||
for (const plan of plans) {
|
||||
for (const sourceRow of plan.result.rows) {
|
||||
if (rows.length >= maxRows) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const row: MultiSourceCellValue[] = Array.from({ length: columns.length }, () => null);
|
||||
row[0] = plan.input.label;
|
||||
plan.targetIndexes.forEach((targetIndex, sourceIndex) => {
|
||||
row[targetIndex] = sourceRow[sourceIndex] ?? null;
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
if (truncated) break;
|
||||
}
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
// The injected provenance column is text by construction, but skip it
|
||||
// explicitly so a numeric-looking label can never become a total.
|
||||
summaries: summarizeNumericColumns(columns, rows, { skipColumnIndexes: [0] }),
|
||||
sources: plans.map((plan) => ({
|
||||
key: plan.input.key,
|
||||
label: plan.input.label,
|
||||
rowCount: plan.result.rows.length,
|
||||
columnCount: plan.targetIndexes.size,
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
truncated,
|
||||
skippedLabels,
|
||||
};
|
||||
}
|
||||
@@ -20,4 +20,71 @@ describe("sqlExecutionDangerStore", () => {
|
||||
expect(await unrelated).toBe(true);
|
||||
expect(store.pending).toBeUndefined();
|
||||
});
|
||||
|
||||
it("asks a batch once and reuses the answer for its remaining targets", async () => {
|
||||
const store = useSqlExecutionDangerStore();
|
||||
const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-1" });
|
||||
expect(store.pending?.scopeId).toBe("batch-1");
|
||||
store.confirm();
|
||||
expect(await first).toBe(true);
|
||||
|
||||
// The second target of the same batch must not prompt (nor ask for the
|
||||
// confirmation code) again.
|
||||
const second = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-1" });
|
||||
expect(store.pending).toBeUndefined();
|
||||
expect(await second).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a declined batch from prompting the rest of its targets", async () => {
|
||||
const store = useSqlExecutionDangerStore();
|
||||
const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-2" });
|
||||
store.cancel();
|
||||
expect(await first).toBe(false);
|
||||
|
||||
const second = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql", scopeId: "batch-2" });
|
||||
expect(store.pending).toBeUndefined();
|
||||
expect(await second).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves already-queued targets of the same batch with the first answer", async () => {
|
||||
const store = useSqlExecutionDangerStore();
|
||||
// Parallel execution can queue several targets before the operator answers.
|
||||
const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-3" });
|
||||
const second = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-3" });
|
||||
const other = store.requestConfirmation({ sql: "UPDATE u", kind: "sql", scopeId: "batch-4" });
|
||||
|
||||
store.confirm();
|
||||
|
||||
expect(await first).toBe(true);
|
||||
expect(await second).toBe(true);
|
||||
expect(store.pending?.scopeId).toBe("batch-4");
|
||||
store.cancel();
|
||||
expect(await other).toBe(false);
|
||||
});
|
||||
|
||||
it("prompts again after the batch scope is cancelled", async () => {
|
||||
const store = useSqlExecutionDangerStore();
|
||||
const first = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-5" });
|
||||
store.confirm();
|
||||
await first;
|
||||
|
||||
store.cancelScope("batch-5");
|
||||
|
||||
const again = store.requestConfirmation({ sql: "UPDATE t", kind: "sql", scopeId: "batch-5" });
|
||||
expect(store.pending?.scopeId).toBe("batch-5");
|
||||
store.cancel();
|
||||
expect(await again).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps prompting for requests without a scope", async () => {
|
||||
const store = useSqlExecutionDangerStore();
|
||||
const first = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql" });
|
||||
store.confirm();
|
||||
expect(await first).toBe(true);
|
||||
|
||||
const second = store.requestConfirmation({ sql: "DROP TABLE a", kind: "sql" });
|
||||
expect(store.pending).toBeDefined();
|
||||
store.cancel();
|
||||
expect(await second).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,12 @@ export interface SqlExecutionDangerRequest {
|
||||
connectionName?: string;
|
||||
database?: string;
|
||||
targetLabel?: string;
|
||||
/**
|
||||
* Every data source the pending batch will touch. Shown in the confirmation
|
||||
* so the operator confirms the whole fan-out, not just the target that
|
||||
* happened to reach the prompt first.
|
||||
*/
|
||||
targets?: string[];
|
||||
databaseType?: DatabaseType;
|
||||
/** Identifies a cancellable execution batch without coupling this store to its orchestrator. */
|
||||
scopeId?: string;
|
||||
@@ -18,13 +24,41 @@ interface QueuedDangerRequest {
|
||||
resolve: (confirmed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A batch asks once per target, but the operator answers once per batch: the
|
||||
* decision is remembered per `scopeId` so the remaining targets reuse it
|
||||
* instead of re-prompting (and re-asking for the confirmation code).
|
||||
*/
|
||||
const MAX_REMEMBERED_SCOPES = 50;
|
||||
|
||||
export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () => {
|
||||
const pending = ref<SqlExecutionDangerRequest>();
|
||||
const queue: QueuedDangerRequest[] = [];
|
||||
const scopeOutcome = new Map<string, boolean>();
|
||||
let resolvePending: ((confirmed: boolean) => void) | undefined;
|
||||
|
||||
function rememberScopeOutcome(scopeId: string | undefined, confirmed: boolean): void {
|
||||
if (!scopeId) return;
|
||||
scopeOutcome.delete(scopeId);
|
||||
scopeOutcome.set(scopeId, confirmed);
|
||||
while (scopeOutcome.size > MAX_REMEMBERED_SCOPES) {
|
||||
const oldest = scopeOutcome.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
scopeOutcome.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberedOutcome(request: SqlExecutionDangerRequest): boolean | undefined {
|
||||
return request.scopeId ? scopeOutcome.get(request.scopeId) : undefined;
|
||||
}
|
||||
|
||||
function requestConfirmation(request: SqlExecutionDangerRequest): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const remembered = rememberedOutcome(request);
|
||||
if (remembered !== undefined) {
|
||||
resolve(remembered);
|
||||
return;
|
||||
}
|
||||
if (pending.value) {
|
||||
queue.push({ request, resolve });
|
||||
return;
|
||||
@@ -38,13 +72,28 @@ export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () =
|
||||
resolvePending = resolve;
|
||||
}
|
||||
|
||||
/** Promotes queued requests, resolving the ones whose batch is already decided. */
|
||||
function promoteNext(): void {
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift()!;
|
||||
const remembered = rememberedOutcome(next.request);
|
||||
if (remembered !== undefined) {
|
||||
next.resolve(remembered);
|
||||
continue;
|
||||
}
|
||||
begin(next.request, next.resolve);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function settle(confirmed: boolean): void {
|
||||
const request = pending.value;
|
||||
const resolve = resolvePending;
|
||||
resolvePending = undefined;
|
||||
pending.value = undefined;
|
||||
rememberScopeOutcome(request?.scopeId, confirmed);
|
||||
resolve?.(confirmed);
|
||||
const next = queue.shift();
|
||||
if (next) begin(next.request, next.resolve);
|
||||
promoteNext();
|
||||
}
|
||||
|
||||
function confirm(): void {
|
||||
@@ -72,6 +121,8 @@ export const useSqlExecutionDangerStore = defineStore("sqlExecutionDanger", () =
|
||||
queue.splice(0, queue.length, ...retained);
|
||||
cancelled.forEach((entry) => entry.resolve(false));
|
||||
if (pending.value?.scopeId === scopeId) settle(false);
|
||||
// A cancelled batch must prompt again if the same scope id is ever reused.
|
||||
scopeOutcome.delete(scopeId);
|
||||
}
|
||||
|
||||
return { pending, requestConfirmation, confirm, cancel, cancelAll, cancelScope };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
export interface MultiDbExecutionTarget {
|
||||
connectionId: string;
|
||||
@@ -43,6 +43,8 @@ export interface MultiDbTargetExecutionResult {
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
transaction?: MultiDbManualTransaction;
|
||||
/** Result produced on this target; feeds the merged multi-source view. */
|
||||
result?: QueryResult;
|
||||
}
|
||||
|
||||
export interface SqlExecutionTargetValidation {
|
||||
|
||||
Reference in new issue
Block a user