mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(mongodb): add Compass-style collection import/export
This commit is contained in:
@@ -271,6 +271,7 @@ async function initializeUpdatePreparation() {
|
||||
dialogs.showTransferDialog,
|
||||
dialogs.showSqlFileDialog,
|
||||
dialogs.showTableImportDialog,
|
||||
dialogs.showMongoImportDialog,
|
||||
dialogs.showTableDataGenerateDialog,
|
||||
dialogs.showDatabaseExportDialog,
|
||||
dialogs.showSchemaDiffDialog,
|
||||
|
||||
@@ -96,6 +96,7 @@ import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/st
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
|
||||
import type { EditNode } from "@/types/editor";
|
||||
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
@@ -2227,6 +2228,15 @@ function focusSearch(): boolean {
|
||||
return documentJsonEditorRef.value?.openSearch() ?? false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => connectionStore.mongoImportCompleted,
|
||||
(completed) => {
|
||||
if (!completed) return;
|
||||
if (completed.connectionId !== props.connectionId || completed.database !== props.database || completed.collection !== props.collection) return;
|
||||
void refreshDocuments();
|
||||
},
|
||||
);
|
||||
|
||||
watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
|
||||
if (mode === "document" && !editing && index !== null) return;
|
||||
documentViewerSearchActive.value = false;
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, FileUp, Loader2, Square, Upload, X } from "@lucide/vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { TABLE_IMPORT_ENCODING_OPTIONS } from "@/lib/table/tableImport";
|
||||
import { importPreviewInput, importSourceDisplayName, importTextDelimiterForName, uploadedImportSourceFromPreview, type UploadedImportSource } from "@/lib/import/importSource";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ completed: [] }>();
|
||||
|
||||
type WizardStep = "source" | "confirm" | "execution";
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const selectedSource = ref<string | File | null>(null);
|
||||
const sourceName = ref("");
|
||||
const format = ref<api.MongoImportFormat>("csv");
|
||||
const encoding = ref<api.TableImportTextEncoding>("auto");
|
||||
const delimiter = ref(",");
|
||||
const hasHeader = ref(true);
|
||||
const trim = ref(false);
|
||||
const emptyAsNull = ref(true);
|
||||
const typeMode = ref<api.MongoImportTypeMode>("auto");
|
||||
const recognizeObjectIdHex = ref(false);
|
||||
const skipErrorRows = ref(false);
|
||||
const batchSize = ref(500);
|
||||
const previewLimit = ref(50);
|
||||
const preview = ref<api.MongoImportPreview | null>(null);
|
||||
const loadingPreview = ref(false);
|
||||
const previewError = ref("");
|
||||
const wizardStep = ref<WizardStep>("source");
|
||||
const running = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const closeBlocked = ref(false);
|
||||
const importId = ref("");
|
||||
const progress = ref<api.MongoImportProgress | null>(null);
|
||||
const errorMessage = ref("");
|
||||
let previewRequestId = 0;
|
||||
let previewReloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let uploadedSource: UploadedImportSource | null = null;
|
||||
|
||||
const connection = computed(() => store.getConfig(props.connectionId));
|
||||
const effectivelyReadOnly = computed(() => connectionIsEffectivelyReadOnly(connection.value));
|
||||
const fileLabel = computed(() => sourceName.value || t("tableImport.noFileSelected"));
|
||||
const encodingOptions = TABLE_IMPORT_ENCODING_OPTIONS;
|
||||
const targetLabel = computed(() => `${props.database}.${props.collection}`);
|
||||
|
||||
const parseOptions = computed(
|
||||
(): api.MongoImportParseOptions => ({
|
||||
encoding: encoding.value,
|
||||
delimiter: delimiter.value,
|
||||
hasHeader: hasHeader.value,
|
||||
trim: trim.value,
|
||||
emptyAsNull: emptyAsNull.value,
|
||||
typeMode: typeMode.value,
|
||||
recognizeObjectIdHex: recognizeObjectIdHex.value,
|
||||
skipErrorRows: skipErrorRows.value,
|
||||
}),
|
||||
);
|
||||
|
||||
function formatFromName(name: string): api.MongoImportFormat {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.endsWith(".ndjson") || lower.endsWith(".jsonl")) return "ndjson";
|
||||
if (lower.endsWith(".json")) return "json";
|
||||
return "csv";
|
||||
}
|
||||
|
||||
function releasePreviewSource() {
|
||||
const sourceRef = uploadedSource?.sourceRef;
|
||||
uploadedSource = null;
|
||||
preview.value = null;
|
||||
if (sourceRef) void api.releaseMongodbImportSource(sourceRef);
|
||||
}
|
||||
|
||||
function assignSource(source: string | File) {
|
||||
releasePreviewSource();
|
||||
selectedSource.value = source;
|
||||
sourceName.value = importSourceDisplayName(source);
|
||||
format.value = formatFromName(sourceName.value);
|
||||
typeMode.value = format.value === "csv" ? "auto" : "extendedJson";
|
||||
delimiter.value = importTextDelimiterForName(sourceName.value);
|
||||
wizardStep.value = "source";
|
||||
queuePreview();
|
||||
}
|
||||
|
||||
async function selectFile() {
|
||||
if (!isTauriRuntime()) {
|
||||
fileInput.value?.click();
|
||||
return;
|
||||
}
|
||||
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: t("mongo.import.fileFilter"), extensions: ["csv", "json", "ndjson", "jsonl", "tsv"] }],
|
||||
});
|
||||
if (!selected || Array.isArray(selected)) return;
|
||||
assignSource(selected);
|
||||
}
|
||||
|
||||
function handleFileInputChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (file) assignSource(file);
|
||||
}
|
||||
|
||||
function queuePreview() {
|
||||
if (!selectedSource.value) return;
|
||||
if (previewReloadTimer) clearTimeout(previewReloadTimer);
|
||||
previewReloadTimer = setTimeout(() => {
|
||||
void loadPreview();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
const source = selectedSource.value;
|
||||
if (!source) return;
|
||||
const requestId = ++previewRequestId;
|
||||
loadingPreview.value = true;
|
||||
previewError.value = "";
|
||||
try {
|
||||
const input = importPreviewInput(uploadedSource, source);
|
||||
const next = await api.previewMongodbImportFile(input.fileOrPath, {
|
||||
format: format.value,
|
||||
parseOptions: parseOptions.value,
|
||||
previewLimit: previewLimit.value,
|
||||
sourceRef: input.sourceRef,
|
||||
});
|
||||
if (requestId !== previewRequestId) return;
|
||||
preview.value = next;
|
||||
uploadedSource = uploadedImportSourceFromPreview(next) ?? uploadedSource;
|
||||
} catch (error) {
|
||||
if (requestId !== previewRequestId) return;
|
||||
previewError.value = error instanceof Error ? error.message : String(error);
|
||||
if (!uploadedSource) preview.value = null;
|
||||
} finally {
|
||||
if (requestId === previewRequestId) loadingPreview.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([format, encoding, delimiter, hasHeader, trim, emptyAsNull, typeMode, recognizeObjectIdHex, previewLimit], () => {
|
||||
if (selectedSource.value) queuePreview();
|
||||
});
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) return;
|
||||
if (running.value) {
|
||||
closeBlocked.value = true;
|
||||
open.value = true;
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
});
|
||||
|
||||
function reset() {
|
||||
releasePreviewSource();
|
||||
selectedSource.value = null;
|
||||
sourceName.value = "";
|
||||
previewError.value = "";
|
||||
wizardStep.value = "source";
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
closeBlocked.value = false;
|
||||
progress.value = null;
|
||||
errorMessage.value = "";
|
||||
importId.value = "";
|
||||
}
|
||||
|
||||
function canConfirm() {
|
||||
return !!preview.value && !loadingPreview.value && !previewError.value && !effectivelyReadOnly.value && (!preview.value.errors.length || skipErrorRows.value);
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
const source = selectedSource.value;
|
||||
const currentPreview = preview.value;
|
||||
if (!source || !currentPreview) return;
|
||||
const reviewText = `db.${props.collection}.insertMany(/* ${currentPreview.estimatedRows ?? "?"} documents from ${sourceName.value} */)`;
|
||||
const started = await executeWithProductionContextGuard({
|
||||
connection: connection.value,
|
||||
database: props.database,
|
||||
reviewText,
|
||||
source: t("readOnlyUnlock.sourceMongoImport"),
|
||||
execute: async () => true,
|
||||
});
|
||||
if (!started) return;
|
||||
running.value = true;
|
||||
wizardStep.value = "execution";
|
||||
errorMessage.value = "";
|
||||
importId.value = uuid();
|
||||
try {
|
||||
const filePath = typeof source === "string" ? source : currentPreview.filePath;
|
||||
const size = Math.min(5000, Math.max(100, Math.trunc(Number(batchSize.value)) || 500));
|
||||
batchSize.value = size;
|
||||
await api.importMongodbFile(
|
||||
{
|
||||
importId: importId.value,
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
collection: props.collection,
|
||||
filePath,
|
||||
sourceRef: currentPreview.sourceRef,
|
||||
format: format.value,
|
||||
parseOptions: parseOptions.value,
|
||||
batchSize: size,
|
||||
},
|
||||
(next) => {
|
||||
progress.value = next;
|
||||
},
|
||||
);
|
||||
if (currentPreview.sourceRef) {
|
||||
await api.releaseMongodbImportSource(currentPreview.sourceRef);
|
||||
if (uploadedSource?.sourceRef === currentPreview.sourceRef) uploadedSource = null;
|
||||
if (preview.value?.sourceRef === currentPreview.sourceRef) preview.value = { ...preview.value, sourceRef: null };
|
||||
}
|
||||
toast(t("mongo.import.success", { count: progress.value?.rowsInserted ?? 0 }));
|
||||
store.mongoImportCompleted = {
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
collection: props.collection,
|
||||
at: Date.now(),
|
||||
};
|
||||
emit("completed");
|
||||
running.value = false;
|
||||
open.value = false;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
running.value = false;
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelImport() {
|
||||
if (!importId.value) return;
|
||||
cancelling.value = true;
|
||||
await api.cancelMongodbImport(importId.value);
|
||||
}
|
||||
|
||||
function formatPreviewCell(value: unknown) {
|
||||
if (value == null) return "null";
|
||||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
function setFormat(value: unknown) {
|
||||
if (value === "csv" || value === "json" || value === "ndjson") format.value = value;
|
||||
}
|
||||
|
||||
function setEncoding(value: unknown) {
|
||||
if (value === "auto" || value === "utf8" || value === "gbk" || value === "utf16Le" || value === "utf16Be") encoding.value = value;
|
||||
}
|
||||
|
||||
function setTypeMode(value: unknown) {
|
||||
if (value === "string" || value === "auto" || value === "extendedJson") typeMode.value = value;
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (running.value) {
|
||||
closeBlocked.value = true;
|
||||
return;
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
@update:open="
|
||||
(value) => {
|
||||
if (!value) requestClose();
|
||||
else open = true;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogScrollContent class="flex max-h-[calc(var(--dbx-viewport-height)-6rem)] min-h-0 flex-col overflow-hidden sm:max-w-[980px]" aria-labelledby="mongo-import-title">
|
||||
<DialogHeader class="shrink-0 pr-8">
|
||||
<DialogTitle id="mongo-import-title" class="flex items-center gap-2 text-base">
|
||||
<FileUp class="h-4 w-4" />
|
||||
{{ t("mongo.import.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto py-2 pr-1 text-sm">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||
<input ref="fileInput" type="file" class="hidden" accept=".csv,.json,.ndjson,.jsonl,.tsv" @change="handleFileInputChange" />
|
||||
<div class="flex h-10 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-3">
|
||||
<span class="shrink-0 text-xs text-muted-foreground">{{ t("mongo.import.target") }}</span>
|
||||
<span class="min-w-0 truncate text-sm font-medium">{{ targetLabel }}</span>
|
||||
</div>
|
||||
<Button variant="outline" class="h-10 px-3" :disabled="running || loadingPreview || wizardStep === 'execution'" @click="selectFile">
|
||||
<Loader2 v-if="loadingPreview" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Upload v-else class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ selectedSource ? t("tableImport.changeFile") : t("tableImport.selectFile") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="effectivelyReadOnly" class="text-xs text-amber-600">{{ t("mongo.import.readonly") }}</div>
|
||||
|
||||
<div v-if="wizardStep === 'source'" class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-3 rounded-md border p-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.sourceFormat") }}</Label>
|
||||
<Select :model-value="format" @update:model-value="setFormat">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="ndjson">NDJSON</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.encoding") }}</Label>
|
||||
<Select :model-value="encoding" @update:model-value="setEncoding">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in encodingOptions" :key="option.value" :value="option.value">{{ t(option.labelKey) }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="format === 'csv'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.delimiter") }}</Label>
|
||||
<Input v-model="delimiter" class="h-8 text-xs font-mono" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("mongo.import.typeMode") }}</Label>
|
||||
<Select :model-value="typeMode" @update:model-value="setTypeMode">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{{ t("mongo.import.typeString") }}</SelectItem>
|
||||
<SelectItem value="auto">{{ t("mongo.import.typeAuto") }}</SelectItem>
|
||||
<SelectItem value="extendedJson">{{ t("mongo.import.typeExtendedJson") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("mongo.import.batchSize") }}</Label>
|
||||
<Input v-model.number="batchSize" type="number" min="100" max="5000" class="h-8 text-xs" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.sourceFile") }}</Label>
|
||||
<div class="flex h-8 items-center rounded-md border px-2 text-xs">
|
||||
<span class="truncate">{{ fileLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border p-3">
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="hasHeader" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("tableImport.hasHeader") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="trim" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("tableImport.trimValues") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="emptyAsNull" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.emptyAsNull") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="recognizeObjectIdHex" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.recognizeObjectIdHex") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="skipErrorRows" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.skipErrorRows") }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPreview" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("mongo.import.previewing") }}
|
||||
</div>
|
||||
<div v-else-if="previewError" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ previewError }}</div>
|
||||
<div v-else-if="preview" class="space-y-2">
|
||||
<div class="text-xs text-muted-foreground">{{ t("mongo.import.estimatedRows", { count: preview.estimatedRows ?? 0 }) }}</div>
|
||||
<div class="max-h-56 overflow-auto rounded-md border">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b px-2 py-1 text-left">#</th>
|
||||
<th v-for="column in preview.columns" :key="column.name" class="border-b px-2 py-1 text-left">
|
||||
{{ column.name }}
|
||||
<span class="text-muted-foreground">({{ column.inferredType }})</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in preview.rows" :key="index">
|
||||
<td class="border-b px-2 py-1">{{ preview.rowNumbers?.[index] ?? index + 1 }}</td>
|
||||
<td v-for="column in preview.columns" :key="column.name" class="border-b px-2 py-1 font-mono">
|
||||
{{ formatPreviewCell(row[column.name]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="preview.errors.length" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<div v-for="(issue, index) in preview.errors" :key="index">{{ issue.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="wizardStep === 'confirm'" class="space-y-2 text-sm">
|
||||
<div>{{ t("mongo.import.confirmAppend") }}</div>
|
||||
<div>{{ t("mongo.import.batchSize") }}: {{ batchSize }}</div>
|
||||
<div>{{ t("mongo.import.duplicateIdPolicy") }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="wizardStep === 'execution'" class="space-y-2 text-sm">
|
||||
<div>{{ t("mongo.import.phase." + (progress?.phase ?? "preparing")) }}</div>
|
||||
<div>{{ t("mongo.import.rowsRead") }}: {{ progress?.rowsRead ?? 0 }}</div>
|
||||
<div>{{ t("mongo.import.rowsInserted") }}: {{ progress?.rowsInserted ?? 0 }}</div>
|
||||
<div>{{ t("mongo.import.rowsFailed") }}: {{ progress?.rowsFailed ?? 0 }}</div>
|
||||
<div>{{ t("mongo.import.batchesCommitted") }}: {{ progress?.batchesCommitted ?? 0 }}</div>
|
||||
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ errorMessage }}</div>
|
||||
<div v-if="closeBlocked" class="flex items-center gap-2 text-amber-600">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
{{ t("mongo.import.stillRunning") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter class="shrink-0">
|
||||
<Button v-if="wizardStep === 'execution' && running" variant="destructive" :disabled="cancelling" @click="cancelImport">
|
||||
<Loader2 v-if="cancelling" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else class="mr-1.5 h-3.5 w-3.5 fill-current" />
|
||||
{{ t("sqlFile.cancel") }}
|
||||
</Button>
|
||||
<Button v-else variant="outline" @click="requestClose">
|
||||
<X class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
<Button v-if="wizardStep === 'confirm'" variant="outline" @click="wizardStep = 'source'">
|
||||
<ArrowLeft class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("tableImport.back") }}
|
||||
</Button>
|
||||
<Button v-if="wizardStep === 'source'" :disabled="!canConfirm()" @click="wizardStep = 'confirm'">
|
||||
<ArrowRight class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("tableImport.next") }}
|
||||
</Button>
|
||||
<Button v-if="wizardStep === 'confirm'" :disabled="effectivelyReadOnly" @click="startImport">
|
||||
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("tableImport.start") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defineComponent, h, nextTick, ref } from "vue";
|
||||
import { createApp } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MongoImportPreview } from "@/lib/backend/api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
previewMongodbImportFile: vi.fn(),
|
||||
importMongodbFile: vi.fn(),
|
||||
cancelMongodbImport: vi.fn(),
|
||||
releaseMongodbImportSource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => api);
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ getConfig: () => ({ id: "c1", name: "mongo", read_only: false }), mongoImportCompleted: null }),
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/lib/database/productionExecutionGuard", () => ({
|
||||
executeWithProductionContextGuard: async ({ execute }: { execute: () => Promise<unknown> }) => execute(),
|
||||
}));
|
||||
|
||||
import MongoImportDialog from "../MongoImportDialog.vue";
|
||||
|
||||
function previewResult(overrides: Partial<MongoImportPreview> = {}): MongoImportPreview {
|
||||
return {
|
||||
sourceRef: "src-1",
|
||||
format: "csv",
|
||||
fileName: "orders.csv",
|
||||
filePath: "/tmp/orders.csv",
|
||||
sizeBytes: 12,
|
||||
columns: [{ name: "id", inferredType: "string" }],
|
||||
rows: [{ id: "1" }],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
estimatedRows: 1,
|
||||
estimatedRowsExact: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MongoImportDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
api.previewMongodbImportFile.mockReset().mockResolvedValue(previewResult());
|
||||
api.releaseMongodbImportSource.mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("keeps the collection target in the wizard", async () => {
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(MongoImportDialog, {
|
||||
open: true,
|
||||
connectionId: "c1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(document.body);
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("shop.orders");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("reuses the uploaded sourceRef on later previews and releases it on close", async () => {
|
||||
const open = ref(true);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(MongoImportDialog, {
|
||||
open: open.value,
|
||||
"onUpdate:open": (value: boolean) => {
|
||||
open.value = value;
|
||||
},
|
||||
connectionId: "c1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(document.body);
|
||||
await nextTick();
|
||||
|
||||
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
||||
const file = new File(["id\n1"], "orders.csv", { type: "text/csv" });
|
||||
Object.defineProperty(input, "files", { value: [file] });
|
||||
input.dispatchEvent(new Event("change"));
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
|
||||
expect(api.previewMongodbImportFile).toHaveBeenCalledTimes(1);
|
||||
const firstSource = api.previewMongodbImportFile.mock.calls[0]?.[0];
|
||||
expect(firstSource).toBeInstanceOf(File);
|
||||
expect((firstSource as File).name).toBe("orders.csv");
|
||||
expect(api.previewMongodbImportFile.mock.calls[0]?.[1]).toMatchObject({ sourceRef: null });
|
||||
|
||||
const header = document.querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
header.click();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
|
||||
expect(api.previewMongodbImportFile).toHaveBeenCalledTimes(2);
|
||||
expect(api.previewMongodbImportFile.mock.calls[1]?.[0]).toBe("/tmp/orders.csv");
|
||||
expect(api.previewMongodbImportFile.mock.calls[1]?.[1]).toMatchObject({ sourceRef: "src-1" });
|
||||
|
||||
open.value = false;
|
||||
await nextTick();
|
||||
expect(api.releaseMongodbImportSource).toHaveBeenCalledWith("src-1");
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
requiredImportTargetColumns,
|
||||
resolveTableImportElapsed,
|
||||
suggestImportTargetDataTypes,
|
||||
TABLE_IMPORT_ENCODING_OPTIONS,
|
||||
tableImportProgressPercent,
|
||||
validateImportMappings,
|
||||
type TableImportWizardStep,
|
||||
} from "@/lib/table/tableImport";
|
||||
import { importPreviewInput, importSourceDisplayName, uploadedImportSourceFromPreview } from "@/lib/import/importSource";
|
||||
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
|
||||
import { metadataSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
@@ -139,13 +141,7 @@ const formatOptions: Array<{ value: api.TableImportSourceFormat; icon: any; labe
|
||||
{ value: "sql", icon: FileCode, labelKey: "tableImport.formatSql", descriptionKey: "tableImport.formatSqlDescription" },
|
||||
];
|
||||
|
||||
const encodingOptions: Array<{ value: api.TableImportTextEncoding; labelKey: string }> = [
|
||||
{ value: "auto", labelKey: "tableImport.encodingAuto" },
|
||||
{ value: "utf8", labelKey: "tableImport.encodingUtf8" },
|
||||
{ value: "gbk", labelKey: "tableImport.encodingGbk" },
|
||||
{ value: "utf16Le", labelKey: "tableImport.encodingUtf16Le" },
|
||||
{ value: "utf16Be", labelKey: "tableImport.encodingUtf16Be" },
|
||||
];
|
||||
const encodingOptions = TABLE_IMPORT_ENCODING_OPTIONS;
|
||||
|
||||
const wizardSteps: Array<{ value: TableImportWizardStep; labelKey: string }> = [
|
||||
{ value: "source", labelKey: "tableImport.stepSource" },
|
||||
@@ -331,7 +327,7 @@ function suggestedTableName(name: string) {
|
||||
}
|
||||
|
||||
function sourceName(source: ImportSource): string {
|
||||
return typeof source === "string" ? source.split(/[\\/]/).pop() || source : source.name;
|
||||
return importSourceDisplayName(source);
|
||||
}
|
||||
|
||||
function uniqueTableName(baseName: string, usedNames: Set<string>): string {
|
||||
@@ -556,9 +552,9 @@ async function loadTargetColumns() {
|
||||
}
|
||||
|
||||
async function previewSelectedImportFile(fileOrPath: string | File) {
|
||||
const reusablePreview = preview.value?.sourceRef ? preview.value : null;
|
||||
return api.previewTableImportFile(reusablePreview?.filePath || fileOrPath, {
|
||||
sourceRef: reusablePreview?.sourceRef || null,
|
||||
const input = importPreviewInput(uploadedImportSourceFromPreview(preview.value), fileOrPath);
|
||||
return api.previewTableImportFile(input.fileOrPath, {
|
||||
sourceRef: input.sourceRef,
|
||||
sourceFormat: sourceFormat.value,
|
||||
parseOptions: parseOptions.value,
|
||||
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
|
||||
@@ -648,11 +644,11 @@ async function prepareBatchSources(sources: ImportSource[]) {
|
||||
});
|
||||
const sheets = format === "excel" && initialPreview.sheets?.length ? initialPreview.sheets : [""];
|
||||
for (const sheetName of sheets) {
|
||||
const reusableSource = initialPreview.sourceRef ? initialPreview.filePath : source;
|
||||
const input = importPreviewInput(uploadedImportSourceFromPreview(initialPreview), source);
|
||||
const effectiveSheetName = sheetName && sheetName === initialPreview.sheets?.[0] ? "" : sheetName;
|
||||
const taskPreview = effectiveSheetName
|
||||
? await api.previewTableImportFile(reusableSource, {
|
||||
sourceRef: initialPreview.sourceRef || null,
|
||||
? await api.previewTableImportFile(input.fileOrPath, {
|
||||
sourceRef: input.sourceRef,
|
||||
sourceFormat: format,
|
||||
parseOptions: taskParseOptions(format, effectiveSheetName),
|
||||
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
|
||||
@@ -1025,9 +1021,9 @@ async function reloadBatchPreviewsForEncoding() {
|
||||
for (const task of batchTasks.value) {
|
||||
// SQL 脚本同样是文本源,编码变化时需要重新预览
|
||||
if (!isDelimitedFormat(task.format) && task.format !== "sql") continue;
|
||||
const reusableSource = task.preview.sourceRef ? task.preview.filePath : task.source;
|
||||
const nextPreview = await api.previewTableImportFile(reusableSource, {
|
||||
sourceRef: task.preview.sourceRef || null,
|
||||
const input = importPreviewInput(uploadedImportSourceFromPreview(task.preview), task.source);
|
||||
const nextPreview = await api.previewTableImportFile(input.fileOrPath, {
|
||||
sourceRef: input.sourceRef,
|
||||
sourceFormat: task.format,
|
||||
parseOptions: taskParseOptions(task.format, task.sheetName),
|
||||
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
|
||||
|
||||
@@ -13,6 +13,7 @@ const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/s
|
||||
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
|
||||
const DatabaseDocsDialog = defineAsyncComponent(() => import("@/components/docs/DatabaseDocsDialog.vue"));
|
||||
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
|
||||
const MongoImportDialog = defineAsyncComponent(() => import("@/components/document/MongoImportDialog.vue"));
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
|
||||
const ConfigConnectionSelectDialog = defineAsyncComponent(() => import("@/components/config/ConfigConnectionSelectDialog.vue"));
|
||||
@@ -295,6 +296,7 @@ watch(
|
||||
:prefill-schema="dialogs.tableImportPrefillSchema.value"
|
||||
:prefill-table="dialogs.tableImportPrefillTable.value"
|
||||
/>
|
||||
<MongoImportDialog v-model:open="dialogs.showMongoImportDialog.value" :connection-id="dialogs.mongoImportPrefillConnectionId.value" :database="dialogs.mongoImportPrefillDatabase.value" :collection="dialogs.mongoImportPrefillCollection.value" />
|
||||
<DataGenerateDialog
|
||||
v-if="dialogs.showTableDataGenerateDialog.value"
|
||||
v-model:open="dialogs.showTableDataGenerateDialog.value"
|
||||
|
||||
@@ -391,21 +391,22 @@ watch(
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
const { copyStructureAs, copyStructureDocText, copyStructurePreview, exportData, exportDataXlsx, exportStructure, saveStructurePreview, selectTextareaContent } = useSidebarTreeExportRuntime({
|
||||
const { copyStructureAs, copyStructureDocText, copyStructurePreview, exportData, exportDataXlsx, exportMongoCollection, exportStructure, saveStructurePreview, selectTextareaContent } = useSidebarTreeExportRuntime({
|
||||
activeNode,
|
||||
connectionStore,
|
||||
settingsStore,
|
||||
acceptedSelectionIds: () => acceptedSelectionIds,
|
||||
});
|
||||
|
||||
const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openScheduledBackups, openSchemaDiff, openSchemaDiffForRoutine, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } = useSidebarTreeToolRuntime({
|
||||
const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openMongoImport, openScheduledBackups, openSchemaDiff, openSchemaDiffForRoutine, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } =
|
||||
useSidebarTreeToolRuntime({
|
||||
activeNode,
|
||||
connectionStore,
|
||||
queryStore,
|
||||
settingsStore,
|
||||
tableChildObjectName: tableChildDropObjectName,
|
||||
acceptedSelectionIds: () => acceptedSelectionIds,
|
||||
});
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"rename-started": [];
|
||||
@@ -5932,6 +5933,15 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
if (canCloneMongoCollection.value) {
|
||||
items.push({ label: t("contextMenu.cloneCollection"), action: openCloneMongoCollectionDialog, icon: CopyPlus });
|
||||
}
|
||||
items.push({ label: t("contextMenu.importData"), action: openMongoImport, icon: Download });
|
||||
items.push({
|
||||
label: t("contextMenu.exportData"),
|
||||
icon: Upload,
|
||||
children: [
|
||||
{ label: "CSV", action: () => void exportMongoCollection("csv") },
|
||||
{ label: "NDJSON", action: () => void exportMongoCollection("ndjson") },
|
||||
],
|
||||
});
|
||||
if (canDropMongoCollection.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function read(relativePath: string) {
|
||||
return readFileSync(resolve(here, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("mongo collection import/export UI", () => {
|
||||
it("adds table-style import/export to the original mongo-collection menu", () => {
|
||||
const menu = read("../SidebarTreeRuntimeHost.vue");
|
||||
const start = menu.indexOf('if (node.type === "mongo-collection") {\n items.push({ label: t("contextMenu.copyName")');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = menu.indexOf("return true;", start);
|
||||
const block = menu.slice(start, end);
|
||||
expect(block).toContain('t("contextMenu.viewData")');
|
||||
expect(block).toContain('t("contextMenu.cloneCollection")');
|
||||
expect(block.indexOf('t("contextMenu.cloneCollection")')).toBeLessThan(block.indexOf('t("contextMenu.importData")'));
|
||||
expect(block).toContain("openMongoImport");
|
||||
expect(block).toContain('t("contextMenu.exportData")');
|
||||
expect(block).toContain('exportMongoCollection("csv")');
|
||||
expect(block).toContain('exportMongoCollection("ndjson")');
|
||||
expect(block).not.toContain("openMongoExport");
|
||||
});
|
||||
|
||||
it("opens import through AppDialogs like table import, without an export setup dialog", () => {
|
||||
const dialogs = read("../../layout/AppDialogs.vue");
|
||||
expect(dialogs).toContain("MongoImportDialog");
|
||||
expect(dialogs).not.toContain("MongoExportDialog");
|
||||
expect(dialogs).toContain("dialogs.showMongoImportDialog.value");
|
||||
|
||||
const sources = read("../../../composables/useDialogSources.ts");
|
||||
expect(sources).toContain("connectionStore.mongoImportSource");
|
||||
expect(sources).not.toContain("connectionStore.mongoExportSource");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const store = reactive({
|
||||
connections: [],
|
||||
mongoImportSource: null as { connectionId: string; database: string; collection: string } | null,
|
||||
transferSource: null,
|
||||
schemaDiffSource: null,
|
||||
dataCompareSource: null,
|
||||
sqlFileSource: null,
|
||||
diagramSource: null,
|
||||
docsSource: null,
|
||||
tableImportSource: null,
|
||||
tableDataGenerateSource: null,
|
||||
fieldLineageSource: null,
|
||||
databaseSearchSource: null,
|
||||
databaseExportSource: null,
|
||||
});
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
import { useDialogSources } from "@/composables/useDialogSources";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
let dialogs: ReturnType<typeof useDialogSources>;
|
||||
|
||||
async function mountDialogs() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
dialogs = useDialogSources();
|
||||
return () => h("div");
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("useDialogSources mongo import/export", () => {
|
||||
beforeAll(async () => {
|
||||
await mountDialogs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
});
|
||||
|
||||
it("opens the mongo import dialog from a collection source", async () => {
|
||||
store.mongoImportSource = { connectionId: "c1", database: "shop", collection: "orders" };
|
||||
await nextTick();
|
||||
expect(dialogs.showMongoImportDialog.value).toBe(true);
|
||||
expect(dialogs.mongoImportPrefillCollection.value).toBe("orders");
|
||||
expect(store.mongoImportSource).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ const apiMock = vi.hoisted(() => ({
|
||||
getColumns: vi.fn(),
|
||||
getTableDdl: vi.fn(),
|
||||
startTableExport: vi.fn(),
|
||||
exportMongodbQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
@@ -122,6 +123,41 @@ describe("useSidebarTreeExportRuntime", () => {
|
||||
expect(toastMock).toHaveBeenCalledWith("导出失败:上一个 DuckDB 查询仍在停止中,请稍后重试。", 5000);
|
||||
});
|
||||
|
||||
it("exports a mongo collection through the save-file path without a setup dialog", async () => {
|
||||
apiMock.exportMongodbQuery.mockImplementation(async (_request, onProgress) => {
|
||||
onProgress({ exportId: "export-1", status: "done", documentsRead: 3, bytesWritten: 12, elapsedMs: 4 });
|
||||
return { exportId: "export-1", documentsExported: 3, filePath: "orders.ndjson", elapsedMs: 4 };
|
||||
});
|
||||
const activeNode = shallowRef({ id: "col-1", type: "mongo-collection", label: "orders", connectionId: "conn-1", database: "shop", children: [] } as TreeNode);
|
||||
const connectionStore = {
|
||||
ensureConnected: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ db_type: "mongodb" })),
|
||||
treeNodes: [],
|
||||
selectedTreeNodeIds: [],
|
||||
};
|
||||
const runtime = useSidebarTreeExportRuntime({
|
||||
activeNode,
|
||||
connectionStore: connectionStore as never,
|
||||
settingsStore: exportSettings() as never,
|
||||
acceptedSelectionIds: () => null,
|
||||
});
|
||||
|
||||
await runtime.exportMongoCollection("ndjson");
|
||||
|
||||
expect(connectionStore.ensureConnected).toHaveBeenCalledWith("conn-1");
|
||||
expect(apiMock.exportMongodbQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionId: "conn-1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
format: "ndjson",
|
||||
filePath: "orders.ndjson",
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith("grid.exported");
|
||||
});
|
||||
|
||||
it("loads and joins every selected DDL in tree order", async () => {
|
||||
apiMock.getTableDdl.mockResolvedValueOnce("CREATE TABLE one (id INT)").mockResolvedValueOnce("CREATE VIEW two AS SELECT 1;");
|
||||
const first = { id: "table-1", type: "table", label: "one", connectionId: "conn-1", database: "db", schema: "main" } as TreeNode;
|
||||
|
||||
+20
@@ -10,6 +10,7 @@ function setup(node: Partial<TreeNode>, options: { treeNodes?: TreeNode[]; selec
|
||||
docsSource: null as unknown,
|
||||
diagramSource: null as unknown,
|
||||
databaseExportSource: null as unknown,
|
||||
mongoImportSource: undefined as unknown,
|
||||
schemaDiffSource: null as unknown,
|
||||
treeNodes: options.treeNodes ?? [],
|
||||
selectedTreeNodeIds: options.selectedTreeNodeIds ?? [],
|
||||
@@ -109,6 +110,25 @@ describe("useSidebarTreeToolRuntime diagram and database export", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarTreeToolRuntime mongo import", () => {
|
||||
it("opens collection import from a mongo-collection node", () => {
|
||||
const { connectionStore, runtime } = setup({
|
||||
type: "mongo-collection",
|
||||
label: "orders",
|
||||
connectionId: "conn-1",
|
||||
database: "shop",
|
||||
});
|
||||
|
||||
runtime.openMongoImport();
|
||||
|
||||
expect(connectionStore.mongoImportSource).toEqual({
|
||||
connectionId: "conn-1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarTreeToolRuntime openSchemaDiffForRoutine", () => {
|
||||
it("prefills schema diff with a signature-aware routine key", () => {
|
||||
const { connectionStore, runtime } = setup({
|
||||
|
||||
@@ -14,6 +14,7 @@ const showSqlFileDialog = ref(false);
|
||||
const showDiagramDialog = ref(false);
|
||||
const showDocsDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
const showMongoImportDialog = ref(false);
|
||||
const showTableDataGenerateDialog = ref(false);
|
||||
const showFieldLineageDialog = ref(false);
|
||||
const showDatabaseSearchDialog = ref(false);
|
||||
@@ -68,6 +69,9 @@ const tableImportPrefillConnectionId = ref("");
|
||||
const tableImportPrefillDatabase = ref("");
|
||||
const tableImportPrefillSchema = ref("");
|
||||
const tableImportPrefillTable = ref("");
|
||||
const mongoImportPrefillConnectionId = ref("");
|
||||
const mongoImportPrefillDatabase = ref("");
|
||||
const mongoImportPrefillCollection = ref("");
|
||||
const tableDataGeneratePrefillConnectionId = ref("");
|
||||
const tableDataGeneratePrefillDatabase = ref("");
|
||||
const tableDataGeneratePrefillSchema = ref("");
|
||||
@@ -246,6 +250,19 @@ export function useDialogSources() {
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.mongoImportSource,
|
||||
(v) => {
|
||||
if (v) {
|
||||
mongoImportPrefillConnectionId.value = v.connectionId;
|
||||
mongoImportPrefillDatabase.value = v.database;
|
||||
mongoImportPrefillCollection.value = v.collection;
|
||||
showMongoImportDialog.value = true;
|
||||
connectionStore.mongoImportSource = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.tableDataGenerateSource,
|
||||
(v) => {
|
||||
@@ -501,6 +518,7 @@ export function useDialogSources() {
|
||||
showDiagramDialog,
|
||||
showDocsDialog,
|
||||
showTableImportDialog,
|
||||
showMongoImportDialog,
|
||||
showTableDataGenerateDialog,
|
||||
showFieldLineageDialog,
|
||||
showDatabaseSearchDialog,
|
||||
@@ -551,6 +569,9 @@ export function useDialogSources() {
|
||||
tableImportPrefillDatabase,
|
||||
tableImportPrefillSchema,
|
||||
tableImportPrefillTable,
|
||||
mongoImportPrefillConnectionId,
|
||||
mongoImportPrefillDatabase,
|
||||
mongoImportPrefillCollection,
|
||||
tableDataGeneratePrefillConnectionId,
|
||||
tableDataGeneratePrefillDatabase,
|
||||
tableDataGeneratePrefillSchema,
|
||||
|
||||
@@ -270,23 +270,34 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
return typeof selected === "string" ? selected : null;
|
||||
}
|
||||
|
||||
async function resolveTableExportOutputPath(target: SidebarTableExportTarget, format: string, outputDirectory?: string): Promise<string | null> {
|
||||
const fileName = `${target.fileNameBase ?? target.tableName}.${format}`;
|
||||
function exportFilterName(format: string): string {
|
||||
if (format === "csv") return "CSV";
|
||||
if (format === "json") return "JSON";
|
||||
if (format === "ndjson") return "NDJSON";
|
||||
if (format === "xlsx") return "Excel";
|
||||
return "SQL";
|
||||
}
|
||||
|
||||
async function resolveExportOutputPath(fileNameBase: string, format: string, outputDirectory?: string): Promise<string | null> {
|
||||
const fileName = `${fileNameBase}.${format}`;
|
||||
if (outputDirectory !== undefined) {
|
||||
return outputDirectory ? joinExportFilePath(outputDirectory, fileName) : fileName;
|
||||
}
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const filterName = format === "csv" ? "CSV" : format === "json" ? "JSON" : format === "xlsx" ? "Excel" : "SQL";
|
||||
const path = await save({
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: filterName, extensions: [format] }],
|
||||
filters: [{ name: exportFilterName(format), extensions: [format] }],
|
||||
});
|
||||
return path ? String(path) : null;
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function resolveTableExportOutputPath(target: SidebarTableExportTarget, format: string, outputDirectory?: string): Promise<string | null> {
|
||||
return resolveExportOutputPath(target.fileNameBase ?? target.tableName, format, outputDirectory);
|
||||
}
|
||||
|
||||
async function exportDataLegacyForTarget(target: SidebarTableExportTarget, outputDirectory?: string, suppressDoneToast = false) {
|
||||
const { connectionId, database } = target;
|
||||
const config = connectionStore.getConfig(connectionId);
|
||||
@@ -461,6 +472,49 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
return pickTableExportDirectory();
|
||||
}
|
||||
|
||||
async function exportMongoCollection(format: "csv" | "ndjson") {
|
||||
const node = activeNode.value;
|
||||
if (node.type !== "mongo-collection" || !node.connectionId || !node.database) return;
|
||||
const outputPath = await resolveExportOutputPath(node.label, format);
|
||||
if (!outputPath) return;
|
||||
let task: ExportTask | null = null;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
task = addExportTask(node.label, format, outputPath);
|
||||
const currentTask = task;
|
||||
await api.exportMongodbQuery(
|
||||
{
|
||||
exportId: currentTask.exportId,
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
collection: node.label,
|
||||
format,
|
||||
includeHeader: true,
|
||||
filePath: outputPath,
|
||||
},
|
||||
(progress) => {
|
||||
currentTask.rowsExported = progress.documentsRead;
|
||||
currentTask.totalRows = progress.totalDocuments ?? null;
|
||||
if (progress.status === "running") currentTask.status = "Writing";
|
||||
else if (progress.status === "done") {
|
||||
currentTask.status = "Done";
|
||||
currentTask.finishedAt = Date.now();
|
||||
} else if (progress.status === "error") {
|
||||
currentTask.status = "Error";
|
||||
currentTask.errorMessage = progress.errorMessage ?? null;
|
||||
} else if (progress.status === "cancelled") currentTask.status = "Cancelled";
|
||||
},
|
||||
);
|
||||
toast(t("grid.exported"));
|
||||
} catch (error: unknown) {
|
||||
if (task) {
|
||||
task.status = "Error";
|
||||
task.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
toast(t("grid.exportFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData(format: "csv" | "json" | "sql") {
|
||||
const targets = currentTableExportTargets();
|
||||
if (!targets.length) return;
|
||||
@@ -531,6 +585,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
copyStructurePreview,
|
||||
exportData,
|
||||
exportDataXlsx,
|
||||
exportMongoCollection,
|
||||
exportStructure,
|
||||
saveStructurePreview,
|
||||
selectTextareaContent,
|
||||
|
||||
@@ -139,6 +139,16 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
|
||||
};
|
||||
}
|
||||
|
||||
function openMongoImport() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId || !node.database || node.type !== "mongo-collection") return;
|
||||
connectionStore.mongoImportSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
collection: node.label,
|
||||
};
|
||||
}
|
||||
|
||||
function openStructureEditor() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
@@ -180,6 +190,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
|
||||
openDiagram,
|
||||
openDocs,
|
||||
openFieldLineage,
|
||||
openMongoImport,
|
||||
openScheduledBackups,
|
||||
openSchemaDiff,
|
||||
openSchemaDiffForRoutine,
|
||||
|
||||
@@ -5353,6 +5353,46 @@ export default withEnglishFallback({
|
||||
tableView: "Cədvəl görünüşü",
|
||||
filterPlaceholder: "Süzgəc...",
|
||||
sortPlaceholder: "Sıralama...",
|
||||
import: {
|
||||
title: "Məlumat idxal et",
|
||||
target: "Hədəf kolleksiya",
|
||||
readonly: "Bu bağlantı yalnız oxuma üçündür. İdxaldan əvvəl yazmanı açın.",
|
||||
chooseFile: "Fayl seçin",
|
||||
fileFilter: "MongoDB məlumatı",
|
||||
noFile: "Fayl seçilməyib",
|
||||
back: "Geri",
|
||||
format: "Format",
|
||||
encoding: "Kodlama",
|
||||
delimiter: "Ayırıcı",
|
||||
typeMode: "Tip rejimi",
|
||||
typeString: "Hamısı sətir",
|
||||
typeAuto: "Avtomatik aşkarlama",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "Birinci sətir başlıqdır",
|
||||
trim: "Dəyərləri kəs",
|
||||
emptyAsNull: "Boş xanaları null yaz",
|
||||
recognizeObjectIdHex: "24 simvollu onaltılığı ObjectId say",
|
||||
skipErrorRows: "Xətalı sətirləri keç",
|
||||
previewing: "Önizləmə yenilənir…",
|
||||
estimatedRows: "Təxminən {count} sənəd",
|
||||
continue: "Davam et",
|
||||
confirmAppend: "Sənədlər əlavə olunacaq. Mövcud sənədlər silinməz və əvəz olunmaz.",
|
||||
batchSize: "Paket ölçüsü",
|
||||
duplicateIdPolicy: "Təkrar _id idxalı dayandırır. Artıq göndərilmiş paketlər geri qaytarılmır.",
|
||||
start: "İdxal et",
|
||||
success: "{count} sənəd idxal olundu",
|
||||
stillRunning: "İdxal hələ işləyir. Yeni paketləri dayandırmaq üçün Ləğv et düyməsini istifadə edin.",
|
||||
rowsRead: "Oxunan sətirlər",
|
||||
rowsInserted: "Əlavə olunan sətirlər",
|
||||
rowsFailed: "Uğursuz sətirlər",
|
||||
batchesCommitted: "Göndərilmiş paketlər",
|
||||
phase: {
|
||||
preparing: "Hazırlanır",
|
||||
parsing: "Ayrışdırılır",
|
||||
writing: "Yazılır",
|
||||
done: "Bitdi",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementAz,
|
||||
@@ -5652,6 +5692,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Yazmanı indi kilidlə",
|
||||
unlockAction: "Yazma kilidini aç…",
|
||||
sourceImport: "Cədvəl idxalı",
|
||||
sourceMongoImport: "MongoDB idxalı",
|
||||
sourceTransfer: "Məlumatların köçürülməsi",
|
||||
sourceSqlFile: "SQL faylının icrası",
|
||||
sourceStatus: "Yalnız oxuma rejiminin idarəsi",
|
||||
|
||||
@@ -5353,6 +5353,46 @@ export default {
|
||||
tableView: "Table View",
|
||||
filterPlaceholder: "Filter...",
|
||||
sortPlaceholder: "Sort...",
|
||||
import: {
|
||||
title: "Import data",
|
||||
target: "Target collection",
|
||||
readonly: "This connection is read-only. Unlock writes before importing.",
|
||||
chooseFile: "Choose file",
|
||||
fileFilter: "MongoDB data",
|
||||
noFile: "No file selected",
|
||||
back: "Back",
|
||||
format: "Format",
|
||||
encoding: "Encoding",
|
||||
delimiter: "Delimiter",
|
||||
typeMode: "Type mode",
|
||||
typeString: "All strings",
|
||||
typeAuto: "Auto detect",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "First row is header",
|
||||
trim: "Trim values",
|
||||
emptyAsNull: "Empty cells as null",
|
||||
recognizeObjectIdHex: "Treat 24-character hex as ObjectId",
|
||||
skipErrorRows: "Skip error rows",
|
||||
previewing: "Refreshing preview…",
|
||||
estimatedRows: "{count} documents estimated",
|
||||
continue: "Continue",
|
||||
confirmAppend: "Documents will be appended. Existing documents are not truncated or upserted.",
|
||||
batchSize: "Batch size",
|
||||
duplicateIdPolicy: "Duplicate _id values stop the import. Already committed batches are not rolled back.",
|
||||
start: "Import",
|
||||
success: "Imported {count} documents",
|
||||
stillRunning: "Import is still running. Use Cancel to stop new batches.",
|
||||
rowsRead: "Rows read",
|
||||
rowsInserted: "Rows inserted",
|
||||
rowsFailed: "Rows failed",
|
||||
batchesCommitted: "Batches committed",
|
||||
phase: {
|
||||
preparing: "Preparing",
|
||||
parsing: "Parsing",
|
||||
writing: "Writing",
|
||||
done: "Done",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementEn,
|
||||
@@ -5652,6 +5692,7 @@ export default {
|
||||
lockNow: "Lock writes now",
|
||||
unlockAction: "Unlock writes…",
|
||||
sourceImport: "Table import",
|
||||
sourceMongoImport: "MongoDB import",
|
||||
sourceTransfer: "Data transfer",
|
||||
sourceSqlFile: "SQL file execution",
|
||||
sourceStatus: "Read-only control",
|
||||
|
||||
@@ -5064,6 +5064,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "Nunca expira",
|
||||
indexOtherOptions: "Otras opciones",
|
||||
indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.",
|
||||
import: {
|
||||
title: "Importar datos",
|
||||
target: "Colección de destino",
|
||||
readonly: "Esta conexión es de solo lectura. Desbloquee la escritura antes de importar.",
|
||||
chooseFile: "Elegir archivo",
|
||||
fileFilter: "Datos de MongoDB",
|
||||
noFile: "Ningún archivo seleccionado",
|
||||
back: "Atrás",
|
||||
format: "Formato",
|
||||
encoding: "Codificación",
|
||||
delimiter: "Delimitador",
|
||||
typeMode: "Modo de tipos",
|
||||
typeString: "Todo como texto",
|
||||
typeAuto: "Detectar automáticamente",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "La primera fila es el encabezado",
|
||||
trim: "Recortar valores",
|
||||
emptyAsNull: "Celdas vacías como null",
|
||||
recognizeObjectIdHex: "Tratar hexadecimal de 24 caracteres como ObjectId",
|
||||
skipErrorRows: "Omitir filas con error",
|
||||
previewing: "Actualizando la vista previa…",
|
||||
estimatedRows: "{count} documentos estimados",
|
||||
continue: "Continuar",
|
||||
confirmAppend: "Los documentos se añadirán. No se truncará ni se hará upsert de los existentes.",
|
||||
batchSize: "Tamaño de lote",
|
||||
duplicateIdPolicy: "Un _id duplicado detiene la importación. Los lotes ya confirmados no se revierten.",
|
||||
start: "Importar",
|
||||
success: "Se importaron {count} documentos",
|
||||
stillRunning: "La importación sigue en curso. Use Cancelar para detener nuevos lotes.",
|
||||
rowsRead: "Filas leídas",
|
||||
rowsInserted: "Filas insertadas",
|
||||
rowsFailed: "Filas fallidas",
|
||||
batchesCommitted: "Lotes confirmados",
|
||||
phase: {
|
||||
preparing: "Preparando",
|
||||
parsing: "Analizando",
|
||||
writing: "Escribiendo",
|
||||
done: "Hecho",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementEs,
|
||||
@@ -8927,6 +8967,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Volver a bloquear ahora",
|
||||
unlockAction: "Desbloquear escrituras…",
|
||||
sourceImport: "Importación de tabla",
|
||||
sourceMongoImport: "Importación de MongoDB",
|
||||
sourceTransfer: "Transferencia de datos",
|
||||
sourceSqlFile: "Ejecución de archivo SQL",
|
||||
sourceStatus: "Control de solo lectura",
|
||||
|
||||
@@ -5062,6 +5062,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "Mai",
|
||||
indexOtherOptions: "Altre opzioni",
|
||||
indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.",
|
||||
import: {
|
||||
title: "Importa dati",
|
||||
target: "Collezione di destinazione",
|
||||
readonly: "Questa connessione è in sola lettura. Sblocca la scrittura prima di importare.",
|
||||
chooseFile: "Scegli file",
|
||||
fileFilter: "Dati MongoDB",
|
||||
noFile: "Nessun file selezionato",
|
||||
back: "Indietro",
|
||||
format: "Formato",
|
||||
encoding: "Codifica",
|
||||
delimiter: "Delimitatore",
|
||||
typeMode: "Modalità tipi",
|
||||
typeString: "Tutto come stringa",
|
||||
typeAuto: "Rilevamento automatico",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "La prima riga è l'intestazione",
|
||||
trim: "Taglia gli spazi",
|
||||
emptyAsNull: "Celle vuote come null",
|
||||
recognizeObjectIdHex: "Tratta l'esadecimale a 24 caratteri come ObjectId",
|
||||
skipErrorRows: "Salta le righe con errore",
|
||||
previewing: "Aggiornamento anteprima…",
|
||||
estimatedRows: "{count} documenti stimati",
|
||||
continue: "Continua",
|
||||
confirmAppend: "I documenti verranno aggiunti. Nessun truncate né upsert.",
|
||||
batchSize: "Dimensione batch",
|
||||
duplicateIdPolicy: "Un _id duplicato interrompe l'importazione. I batch già confermati non vengono annullati.",
|
||||
start: "Importa",
|
||||
success: "Importati {count} documenti",
|
||||
stillRunning: "L'importazione è ancora in esecuzione. Usa Annulla per fermare i nuovi batch.",
|
||||
rowsRead: "Righe lette",
|
||||
rowsInserted: "Righe inserite",
|
||||
rowsFailed: "Righe non riuscite",
|
||||
batchesCommitted: "Batch confermati",
|
||||
phase: {
|
||||
preparing: "Preparazione",
|
||||
parsing: "Analisi",
|
||||
writing: "Scrittura",
|
||||
done: "Completato",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementIt,
|
||||
@@ -8928,6 +8968,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Blocca scritture ora",
|
||||
unlockAction: "Sblocca scritture…",
|
||||
sourceImport: "Importazione tabella",
|
||||
sourceMongoImport: "Importazione MongoDB",
|
||||
sourceTransfer: "Trasferimento dati",
|
||||
sourceSqlFile: "Esecuzione file SQL",
|
||||
sourceStatus: "Controllo sola lettura",
|
||||
|
||||
@@ -5091,6 +5091,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "期限なし",
|
||||
indexOtherOptions: "その他のオプション",
|
||||
indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。",
|
||||
import: {
|
||||
title: "データをインポート",
|
||||
target: "対象コレクション",
|
||||
readonly: "この接続は読み取り専用です。インポート前に書き込みを解除してください。",
|
||||
chooseFile: "ファイルを選択",
|
||||
fileFilter: "MongoDB データ",
|
||||
noFile: "ファイル未選択",
|
||||
back: "戻る",
|
||||
format: "形式",
|
||||
encoding: "エンコーディング",
|
||||
delimiter: "区切り文字",
|
||||
typeMode: "型モード",
|
||||
typeString: "すべて文字列",
|
||||
typeAuto: "自動検出",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "先頭行をヘッダーにする",
|
||||
trim: "値をトリム",
|
||||
emptyAsNull: "空セルを null にする",
|
||||
recognizeObjectIdHex: "24 桁の 16 進数を ObjectId として扱う",
|
||||
skipErrorRows: "エラー行をスキップ",
|
||||
previewing: "プレビューを更新中…",
|
||||
estimatedRows: "推定 {count} 件",
|
||||
continue: "続行",
|
||||
confirmAppend: "ドキュメントは追加されます。既存ドキュメントの切り捨てや upsert は行いません。",
|
||||
batchSize: "バッチサイズ",
|
||||
duplicateIdPolicy: "重複した _id でインポートは停止します。すでに確定したバッチはロールバックされません。",
|
||||
start: "インポート",
|
||||
success: "{count} 件のドキュメントをインポートしました",
|
||||
stillRunning: "インポートは実行中です。以降のバッチを止めるにはキャンセルしてください。",
|
||||
rowsRead: "読み取り行数",
|
||||
rowsInserted: "挿入行数",
|
||||
rowsFailed: "失敗行数",
|
||||
batchesCommitted: "確定バッチ数",
|
||||
phase: {
|
||||
preparing: "準備中",
|
||||
parsing: "解析中",
|
||||
writing: "書き込み中",
|
||||
done: "完了",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementJa,
|
||||
@@ -8979,6 +9019,7 @@ export default withEnglishFallback({
|
||||
lockNow: "今すぐ再ロック",
|
||||
unlockAction: "書き込みのロックを解除…",
|
||||
sourceImport: "テーブルのインポート",
|
||||
sourceMongoImport: "MongoDB のインポート",
|
||||
sourceTransfer: "データ転送",
|
||||
sourceSqlFile: "SQL ファイル実行",
|
||||
sourceStatus: "読み取り専用コントロール",
|
||||
|
||||
@@ -4690,6 +4690,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "만료 안 함",
|
||||
indexOtherOptions: "기타 옵션",
|
||||
indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.",
|
||||
import: {
|
||||
title: "데이터 가져오기",
|
||||
target: "대상 컬렉션",
|
||||
readonly: "이 연결은 읽기 전용입니다. 가져오기 전에 쓰기를 잠금 해제하세요.",
|
||||
chooseFile: "파일 선택",
|
||||
fileFilter: "MongoDB 데이터",
|
||||
noFile: "선택된 파일 없음",
|
||||
back: "뒤로",
|
||||
format: "형식",
|
||||
encoding: "인코딩",
|
||||
delimiter: "구분 기호",
|
||||
typeMode: "유형 모드",
|
||||
typeString: "모두 문자열",
|
||||
typeAuto: "자동 감지",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "첫 행을 헤더로 사용",
|
||||
trim: "값 앞뒤 공백 제거",
|
||||
emptyAsNull: "빈 셀을 null로 기록",
|
||||
recognizeObjectIdHex: "24자 16진수를 ObjectId로 인식",
|
||||
skipErrorRows: "오류 행 건너뛰기",
|
||||
previewing: "미리보기 새로 고치는 중…",
|
||||
estimatedRows: "약 {count}개 문서",
|
||||
continue: "계속",
|
||||
confirmAppend: "문서는 추가됩니다. 기존 문서는 비우거나 upsert하지 않습니다.",
|
||||
batchSize: "배치 크기",
|
||||
duplicateIdPolicy: "중복 _id는 가져오기를 중지합니다. 이미 커밋된 배치는 롤백되지 않습니다.",
|
||||
start: "가져오기",
|
||||
success: "{count}개 문서를 가져왔습니다",
|
||||
stillRunning: "가져오기가 계속 실행 중입니다. 이후 배치를 멈추려면 취소를 사용하세요.",
|
||||
rowsRead: "읽은 행",
|
||||
rowsInserted: "삽입된 행",
|
||||
rowsFailed: "실패한 행",
|
||||
batchesCommitted: "커밋된 배치",
|
||||
phase: {
|
||||
preparing: "준비 중",
|
||||
parsing: "구문 분석 중",
|
||||
writing: "쓰는 중",
|
||||
done: "완료",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementKo,
|
||||
@@ -4988,6 +5028,7 @@ export default withEnglishFallback({
|
||||
lockNow: "지금 다시 잠금",
|
||||
unlockAction: "쓰기 잠금 해제…",
|
||||
sourceImport: "테이블 가져오기",
|
||||
sourceMongoImport: "MongoDB 가져오기",
|
||||
sourceTransfer: "데이터 이전",
|
||||
sourceSqlFile: "SQL 파일 실행",
|
||||
sourceStatus: "읽기 전용 컨트롤",
|
||||
|
||||
@@ -5064,6 +5064,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "Nunca",
|
||||
indexOtherOptions: "Outras opções",
|
||||
indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.",
|
||||
import: {
|
||||
title: "Importar dados",
|
||||
target: "Coleção de destino",
|
||||
readonly: "Esta conexão é somente leitura. Desbloqueie a gravação antes de importar.",
|
||||
chooseFile: "Escolher arquivo",
|
||||
fileFilter: "Dados do MongoDB",
|
||||
noFile: "Nenhum arquivo selecionado",
|
||||
back: "Voltar",
|
||||
format: "Formato",
|
||||
encoding: "Codificação",
|
||||
delimiter: "Delimitador",
|
||||
typeMode: "Modo de tipos",
|
||||
typeString: "Tudo como texto",
|
||||
typeAuto: "Detectar automaticamente",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "A primeira linha é o cabeçalho",
|
||||
trim: "Remover espaços",
|
||||
emptyAsNull: "Células vazias como null",
|
||||
recognizeObjectIdHex: "Tratar hexadecimal de 24 caracteres como ObjectId",
|
||||
skipErrorRows: "Ignorar linhas com erro",
|
||||
previewing: "Atualizando a prévia…",
|
||||
estimatedRows: "{count} documentos estimados",
|
||||
continue: "Continuar",
|
||||
confirmAppend: "Os documentos serão acrescentados. Não há truncate nem upsert.",
|
||||
batchSize: "Tamanho do lote",
|
||||
duplicateIdPolicy: "Um _id duplicado interrompe a importação. Lotes já confirmados não são revertidos.",
|
||||
start: "Importar",
|
||||
success: "{count} documentos importados",
|
||||
stillRunning: "A importação ainda está em execução. Use Cancelar para interromper novos lotes.",
|
||||
rowsRead: "Linhas lidas",
|
||||
rowsInserted: "Linhas inseridas",
|
||||
rowsFailed: "Linhas com falha",
|
||||
batchesCommitted: "Lotes confirmados",
|
||||
phase: {
|
||||
preparing: "Preparando",
|
||||
parsing: "Analisando",
|
||||
writing: "Gravando",
|
||||
done: "Concluído",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementPtBR,
|
||||
@@ -8929,6 +8969,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Bloquear gravações agora",
|
||||
unlockAction: "Desbloquear gravações…",
|
||||
sourceImport: "Importação de tabela",
|
||||
sourceMongoImport: "Importação do MongoDB",
|
||||
sourceTransfer: "Transferência de dados",
|
||||
sourceSqlFile: "Execução de arquivo SQL",
|
||||
sourceStatus: "Controle de somente leitura",
|
||||
|
||||
@@ -5247,6 +5247,46 @@ export default withEnglishFallback({
|
||||
tableView: "Tablo Görünümü",
|
||||
filterPlaceholder: "Filtrele...",
|
||||
sortPlaceholder: "Sırala...",
|
||||
import: {
|
||||
title: "Veri içe aktar",
|
||||
target: "Hedef koleksiyon",
|
||||
readonly: "Bu bağlantı salt okunur. İçe aktarmadan önce yazmayı açın.",
|
||||
chooseFile: "Dosya seç",
|
||||
fileFilter: "MongoDB verisi",
|
||||
noFile: "Dosya seçilmedi",
|
||||
back: "Geri",
|
||||
format: "Biçim",
|
||||
encoding: "Kodlama",
|
||||
delimiter: "Ayırıcı",
|
||||
typeMode: "Tür kipi",
|
||||
typeString: "Tümü metin",
|
||||
typeAuto: "Otomatik algıla",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "İlk satır başlıktır",
|
||||
trim: "Değerleri kırp",
|
||||
emptyAsNull: "Boş hücreleri null yaz",
|
||||
recognizeObjectIdHex: "24 karakterlik onaltılığı ObjectId say",
|
||||
skipErrorRows: "Hatalı satırları atla",
|
||||
previewing: "Önizleme yenileniyor…",
|
||||
estimatedRows: "Yaklaşık {count} belge",
|
||||
continue: "Devam",
|
||||
confirmAppend: "Belgeler eklenecek. Mevcut belgeler silinmez veya üzerine yazılmaz.",
|
||||
batchSize: "Toplu boyut",
|
||||
duplicateIdPolicy: "Yinelenen _id içe aktarmayı durdurur. Gönderilmiş toplu işler geri alınmaz.",
|
||||
start: "İçe aktar",
|
||||
success: "{count} belge içe aktarıldı",
|
||||
stillRunning: "İçe aktarma hâlâ çalışıyor. Yeni toplu işleri durdurmak için İptal’i kullanın.",
|
||||
rowsRead: "Okunan satırlar",
|
||||
rowsInserted: "Eklenen satırlar",
|
||||
rowsFailed: "Başarısız satırlar",
|
||||
batchesCommitted: "Gönderilen toplu işler",
|
||||
phase: {
|
||||
preparing: "Hazırlanıyor",
|
||||
parsing: "Ayrıştırılıyor",
|
||||
writing: "Yazılıyor",
|
||||
done: "Bitti",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementTr,
|
||||
@@ -5546,6 +5586,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Yazmaları şimdi kilitle",
|
||||
unlockAction: "Yazmaların kilidini aç…",
|
||||
sourceImport: "Tablo içe aktarma",
|
||||
sourceMongoImport: "MongoDB içe aktarma",
|
||||
sourceTransfer: "Veri aktarımı",
|
||||
sourceSqlFile: "SQL dosyası yürütme",
|
||||
sourceStatus: "Salt okunur denetimi",
|
||||
|
||||
@@ -5337,6 +5337,46 @@ export default withEnglishFallback({
|
||||
tableView: "表格视图",
|
||||
filterPlaceholder: "过滤条件...",
|
||||
sortPlaceholder: "排序条件...",
|
||||
import: {
|
||||
title: "导入数据",
|
||||
target: "目标 Collection",
|
||||
readonly: "当前连接为只读,导入前需要先解锁写入。",
|
||||
chooseFile: "选择文件",
|
||||
fileFilter: "MongoDB 数据",
|
||||
noFile: "尚未选择文件",
|
||||
back: "上一步",
|
||||
format: "格式",
|
||||
encoding: "编码",
|
||||
delimiter: "分隔符",
|
||||
typeMode: "类型策略",
|
||||
typeString: "全部按字符串",
|
||||
typeAuto: "自动推断",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "首行是表头",
|
||||
trim: "去除首尾空白",
|
||||
emptyAsNull: "空单元格写入 null",
|
||||
recognizeObjectIdHex: "将 24 位十六进制识别为 ObjectId",
|
||||
skipErrorRows: "跳过错误行",
|
||||
previewing: "正在刷新预览…",
|
||||
estimatedRows: "估计 {count} 个文档",
|
||||
continue: "继续",
|
||||
confirmAppend: "将以追加方式写入,不会清空或覆盖已有文档。",
|
||||
batchSize: "批次大小",
|
||||
duplicateIdPolicy: "重复 _id 会停止导入。已提交的批次不会回滚。",
|
||||
start: "开始导入",
|
||||
success: "已导入 {count} 个文档",
|
||||
stillRunning: "导入仍在运行。如需停止后续批次,请点取消。",
|
||||
rowsRead: "已读取",
|
||||
rowsInserted: "已插入",
|
||||
rowsFailed: "失败",
|
||||
batchesCommitted: "已提交批次",
|
||||
phase: {
|
||||
preparing: "准备中",
|
||||
parsing: "解析中",
|
||||
writing: "写入中",
|
||||
done: "完成",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementZhCN,
|
||||
@@ -5636,6 +5676,7 @@ export default withEnglishFallback({
|
||||
lockNow: "立即重新锁定",
|
||||
unlockAction: "解锁写入…",
|
||||
sourceImport: "表导入",
|
||||
sourceMongoImport: "MongoDB 导入",
|
||||
sourceTransfer: "数据传输",
|
||||
sourceSqlFile: "SQL 文件执行",
|
||||
sourceStatus: "只读控制",
|
||||
|
||||
@@ -4379,6 +4379,46 @@ export default withEnglishFallback({
|
||||
tableView: "表格檢視",
|
||||
filterPlaceholder: "過濾條件……",
|
||||
sortPlaceholder: "排序條件……",
|
||||
import: {
|
||||
title: "匯入資料",
|
||||
target: "目標 Collection",
|
||||
readonly: "目前連線為唯讀,匯入前需要先解鎖寫入。",
|
||||
chooseFile: "選擇檔案",
|
||||
fileFilter: "MongoDB 資料",
|
||||
noFile: "尚未選擇檔案",
|
||||
back: "上一步",
|
||||
format: "格式",
|
||||
encoding: "編碼",
|
||||
delimiter: "分隔符",
|
||||
typeMode: "類型策略",
|
||||
typeString: "全部按字串",
|
||||
typeAuto: "自動推斷",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "首列是表頭",
|
||||
trim: "去除首尾空白",
|
||||
emptyAsNull: "空儲存格寫入 null",
|
||||
recognizeObjectIdHex: "將 24 位十六進位識別為 ObjectId",
|
||||
skipErrorRows: "跳過錯誤列",
|
||||
previewing: "正在重新整理預覽…",
|
||||
estimatedRows: "估計 {count} 個文件",
|
||||
continue: "繼續",
|
||||
confirmAppend: "將以附加方式寫入,不會清空或覆蓋既有文件。",
|
||||
batchSize: "批次大小",
|
||||
duplicateIdPolicy: "重複 _id 會停止匯入。已提交的批次不會回溯。",
|
||||
start: "開始匯入",
|
||||
success: "已匯入 {count} 個文件",
|
||||
stillRunning: "匯入仍在執行。如需停止後續批次,請點取消。",
|
||||
rowsRead: "已讀取",
|
||||
rowsInserted: "已插入",
|
||||
rowsFailed: "失敗",
|
||||
batchesCommitted: "已提交批次",
|
||||
phase: {
|
||||
preparing: "準備中",
|
||||
parsing: "解析中",
|
||||
writing: "寫入中",
|
||||
done: "完成",
|
||||
},
|
||||
},
|
||||
fieldEditor: "欄位",
|
||||
nativeJson: "原生 JSON",
|
||||
nativeJsonHint: "BSON 值請使用 MongoDB Extended JSON,例如 $oid、$date、$numberLong。",
|
||||
@@ -8917,6 +8957,7 @@ export default withEnglishFallback({
|
||||
lockNow: "立即重新鎖定",
|
||||
unlockAction: "解鎖寫入…",
|
||||
sourceImport: "資料表匯入",
|
||||
sourceMongoImport: "MongoDB 匯入",
|
||||
sourceTransfer: "資料傳輸",
|
||||
sourceSqlFile: "SQL 檔案執行",
|
||||
sourceStatus: "唯讀控制",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { importPreviewInput, importSourceDisplayName, importTextDelimiterForName, uploadedImportSourceFromPreview } from "@/lib/import/importSource";
|
||||
|
||||
describe("importSource", () => {
|
||||
it("reuses an uploaded sourceRef and server path on later previews", () => {
|
||||
const file = new File(["id\n1"], "orders.csv");
|
||||
const first = importPreviewInput(null, file);
|
||||
expect(first).toEqual({ fileOrPath: file, sourceRef: null });
|
||||
|
||||
const uploaded = uploadedImportSourceFromPreview({ sourceRef: "src-1", filePath: "/tmp/orders.csv" });
|
||||
expect(importPreviewInput(uploaded, file)).toEqual({ fileOrPath: "/tmp/orders.csv", sourceRef: "src-1" });
|
||||
});
|
||||
|
||||
it("keeps a local path preview on desktop when nothing has been uploaded", () => {
|
||||
expect(importPreviewInput(null, "/data/orders.csv")).toEqual({ fileOrPath: "/data/orders.csv", sourceRef: null });
|
||||
});
|
||||
|
||||
it("matches table-import TSV delimiter encoding and source labels", () => {
|
||||
expect(importTextDelimiterForName("orders.tsv")).toBe("\\t");
|
||||
expect(importTextDelimiterForName("orders.csv")).toBe(",");
|
||||
expect(importSourceDisplayName("/tmp/dir/orders.csv")).toBe("orders.csv");
|
||||
expect(importSourceDisplayName(new File(["x"], "people.json"))).toBe("people.json");
|
||||
});
|
||||
});
|
||||
@@ -458,6 +458,12 @@ export const previewTableImportFile = forward("previewTableImportFile");
|
||||
export const importTableFile = forward("importTableFile");
|
||||
export const cancelTableImport = forward("cancelTableImport");
|
||||
export const releaseTableImportSource = forward("releaseTableImportSource");
|
||||
export const previewMongodbImportFile = forward("previewMongodbImportFile");
|
||||
export const importMongodbFile = forward("importMongodbFile");
|
||||
export const cancelMongodbImport = forward("cancelMongodbImport");
|
||||
export const releaseMongodbImportSource = forward("releaseMongodbImportSource");
|
||||
export const exportMongodbQuery = forward("exportMongodbQuery");
|
||||
export const cancelMongodbExport = forward("cancelMongodbExport");
|
||||
|
||||
// Database Export
|
||||
export const beginDatabaseBackupSnapshot = forward("beginDatabaseBackupSnapshot");
|
||||
@@ -995,6 +1001,19 @@ export type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
MongoImportFormat,
|
||||
MongoImportTypeMode,
|
||||
MongoImportIssue,
|
||||
MongoImportParseOptions,
|
||||
MongoImportPreviewRequest,
|
||||
MongoImportPreview,
|
||||
MongoImportRequest,
|
||||
MongoImportProgress,
|
||||
MongoImportSummary,
|
||||
MongoExportFormat,
|
||||
MongoExportRequest,
|
||||
MongoExportProgress,
|
||||
MongoExportSummary,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
TableExportProgress,
|
||||
|
||||
@@ -132,6 +132,14 @@ import type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
MongoImportPreviewRequest,
|
||||
MongoImportPreview,
|
||||
MongoImportRequest,
|
||||
MongoImportProgress,
|
||||
MongoImportSummary,
|
||||
MongoExportRequest,
|
||||
MongoExportProgress,
|
||||
MongoExportSummary,
|
||||
DatabaseBackupSnapshot,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
@@ -2469,6 +2477,122 @@ export async function releaseTableImportSource(sourceRef: string): Promise<boole
|
||||
return result.released;
|
||||
}
|
||||
|
||||
export async function previewMongodbImportFile(fileOrPath: string | File | MongoImportPreviewRequest, options: Partial<MongoImportPreviewRequest> = {}): Promise<MongoImportPreview> {
|
||||
if (typeof fileOrPath === "object" && !(fileOrPath instanceof File)) {
|
||||
throw new Error("previewMongodbImportFile in web mode requires a File object for upload previews");
|
||||
}
|
||||
if (typeof fileOrPath === "string") {
|
||||
if (!options.sourceRef) {
|
||||
throw new Error("previewMongodbImportFile in web mode requires a File object for new uploads");
|
||||
}
|
||||
const res = await fetch(apiUrl("/api/mongo/import/preview-source"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceRef: options.sourceRef,
|
||||
format: options.format,
|
||||
parseOptions: options.parseOptions,
|
||||
previewLimit: options.previewLimit,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileOrPath);
|
||||
if (options.format) formData.append("format", options.format);
|
||||
if (options.parseOptions) formData.append("parseOptions", JSON.stringify(options.parseOptions));
|
||||
if (options.previewLimit != null) formData.append("previewLimit", String(options.previewLimit));
|
||||
const res = await fetch(apiUrl("/api/mongo/import/preview"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function importMongodbFile(request: MongoImportRequest, onProgress: (progress: MongoImportProgress) => void): Promise<MongoImportSummary> {
|
||||
const res = await fetch(apiUrl("/api/mongo/import/execute"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return new Promise((resolve, reject) => {
|
||||
const es = new EventSource(apiUrl(`/api/mongo/import/progress/${request.importId}`));
|
||||
es.onmessage = (e) => {
|
||||
const progress: MongoImportProgress = JSON.parse(e.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "done") {
|
||||
es.close();
|
||||
resolve({
|
||||
importId: progress.importId,
|
||||
rowsInserted: progress.rowsInserted,
|
||||
rowsFailed: progress.rowsFailed,
|
||||
batchesCommitted: progress.batchesCommitted,
|
||||
elapsedMs: progress.elapsedMs,
|
||||
});
|
||||
} else if (progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
reject(new Error(progress.errorMessage || "MongoDB import failed"));
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error("MongoDB import SSE connection failed"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMongodbImport(importId: string): Promise<boolean> {
|
||||
return post("/api/mongo/import/cancel", { importId });
|
||||
}
|
||||
|
||||
export async function releaseMongodbImportSource(sourceRef: string): Promise<boolean> {
|
||||
const result = await post<{ released: boolean }>("/api/mongo/import/source/release", { sourceRef });
|
||||
return result.released;
|
||||
}
|
||||
|
||||
export async function exportMongodbQuery(request: MongoExportRequest, onProgress: (progress: MongoExportProgress) => void): Promise<MongoExportSummary> {
|
||||
const res = await fetch(apiUrl("/api/mongo/export"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return new Promise((resolve, reject) => {
|
||||
const es = new EventSource(apiUrl(`/api/mongo/export/progress/${request.exportId}`));
|
||||
es.onmessage = (e) => {
|
||||
const progress: MongoExportProgress = JSON.parse(e.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "done" || progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
if (progress.status === "done") {
|
||||
const a = document.createElement("a");
|
||||
a.href = apiUrl(`/api/mongo/export/download/${request.exportId}`);
|
||||
a.click();
|
||||
resolve({
|
||||
exportId: progress.exportId,
|
||||
documentsExported: progress.documentsRead,
|
||||
filePath: request.filePath,
|
||||
elapsedMs: progress.elapsedMs,
|
||||
});
|
||||
} else {
|
||||
reject(new Error(progress.errorMessage || "MongoDB export failed"));
|
||||
}
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error("MongoDB export SSE connection failed"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMongodbExport(exportId: string): Promise<boolean> {
|
||||
return post("/api/mongo/export/cancel", { exportId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4900,6 +4900,190 @@ export async function releaseTableImportSource(_sourceRef: string): Promise<bool
|
||||
return false;
|
||||
}
|
||||
|
||||
export type MongoImportFormat = "csv" | "json" | "ndjson";
|
||||
export type MongoImportTypeMode = "string" | "auto" | "extendedJson";
|
||||
export type MongoImportInferredType = "boolean" | "integer" | "decimal" | "date" | "object" | "array" | "string";
|
||||
export type MongoImportStatus = "running" | "done" | "error" | "cancelled";
|
||||
export type MongoImportPhase = "preparing" | "parsing" | "writing" | "done";
|
||||
export type MongoExportFormat = "csv" | "ndjson";
|
||||
export type MongoExportStatus = "running" | "done" | "error" | "cancelled";
|
||||
|
||||
export interface MongoImportIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
row?: number | null;
|
||||
column?: string | null;
|
||||
value?: string | null;
|
||||
batch?: number | null;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface MongoImportParseOptions {
|
||||
encoding?: TableImportTextEncoding | null;
|
||||
delimiter?: string | null;
|
||||
hasHeader?: boolean | null;
|
||||
trim?: boolean | null;
|
||||
emptyAsNull?: boolean | null;
|
||||
typeMode?: MongoImportTypeMode | null;
|
||||
recognizeObjectIdHex?: boolean | null;
|
||||
skipErrorRows?: boolean | null;
|
||||
}
|
||||
|
||||
export interface MongoImportPreviewRequest {
|
||||
filePath: string;
|
||||
sourceRef?: string | null;
|
||||
format: MongoImportFormat;
|
||||
parseOptions?: MongoImportParseOptions;
|
||||
previewLimit?: number | null;
|
||||
}
|
||||
|
||||
export interface MongoImportColumn {
|
||||
name: string;
|
||||
inferredType: MongoImportInferredType;
|
||||
sampleValues?: unknown[];
|
||||
}
|
||||
|
||||
export interface MongoImportPreview {
|
||||
sourceRef?: string | null;
|
||||
format: MongoImportFormat;
|
||||
detectedEncoding?: TableImportTextEncoding | null;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
sizeBytes: number;
|
||||
columns: MongoImportColumn[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowNumbers?: number[];
|
||||
warnings: MongoImportIssue[];
|
||||
errors: MongoImportIssue[];
|
||||
estimatedRows?: number | null;
|
||||
estimatedRowsExact: boolean;
|
||||
}
|
||||
|
||||
export interface MongoImportRequest {
|
||||
importId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
filePath: string;
|
||||
sourceRef?: string | null;
|
||||
format: MongoImportFormat;
|
||||
parseOptions?: MongoImportParseOptions;
|
||||
batchSize: number;
|
||||
executionId?: string | null;
|
||||
}
|
||||
|
||||
export interface MongoImportProgress {
|
||||
importId: string;
|
||||
phase: MongoImportPhase;
|
||||
status: MongoImportStatus;
|
||||
rowsRead: number;
|
||||
rowsInserted: number;
|
||||
rowsFailed: number;
|
||||
batchesCommitted: number;
|
||||
totalRows?: number | null;
|
||||
errorRows?: MongoImportIssue[];
|
||||
errorMessage?: string | null;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface MongoImportSummary {
|
||||
importId: string;
|
||||
rowsInserted: number;
|
||||
rowsFailed: number;
|
||||
batchesCommitted: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface MongoExportRequest {
|
||||
exportId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
filter?: string | null;
|
||||
sort?: string | null;
|
||||
projection?: string | null;
|
||||
collation?: string | null;
|
||||
format: MongoExportFormat;
|
||||
includeHeader?: boolean;
|
||||
filePath: string;
|
||||
executionId?: string | null;
|
||||
}
|
||||
|
||||
export interface MongoExportProgress {
|
||||
exportId: string;
|
||||
status: MongoExportStatus;
|
||||
documentsRead: number;
|
||||
bytesWritten: number;
|
||||
totalDocuments?: number | null;
|
||||
errorMessage?: string | null;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface MongoExportSummary {
|
||||
exportId: string;
|
||||
documentsExported: number;
|
||||
filePath: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export async function previewMongodbImportFile(filePathOrRequest: string | File | MongoImportPreviewRequest, options: Partial<MongoImportPreviewRequest> = {}): Promise<MongoImportPreview> {
|
||||
if (typeof filePathOrRequest !== "string" && !("filePath" in filePathOrRequest)) {
|
||||
throw new Error("previewMongodbImportFile in desktop mode requires a file path, not a File object");
|
||||
}
|
||||
const request: MongoImportPreviewRequest = typeof filePathOrRequest === "string" ? { format: options.format ?? "csv", ...options, filePath: filePathOrRequest } : filePathOrRequest;
|
||||
return invoke("preview_mongodb_import_file", { request });
|
||||
}
|
||||
|
||||
export async function importMongodbFile(request: MongoImportRequest, onProgress: (progress: MongoImportProgress) => void): Promise<MongoImportSummary> {
|
||||
const unlisten: UnlistenFn = await listen<MongoImportProgress>("mongo-import-progress", (event) => {
|
||||
if (event.payload.importId === request.importId) {
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "done" || event.payload.status === "error" || event.payload.status === "cancelled") {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
const summary = await invoke<MongoImportSummary>("import_mongodb_file", { request });
|
||||
unlisten();
|
||||
return summary;
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e instanceof BackendErrorException ? e : new BackendErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMongodbImport(importId: string): Promise<boolean> {
|
||||
return invoke("cancel_mongodb_import", { importId });
|
||||
}
|
||||
|
||||
export async function releaseMongodbImportSource(_sourceRef: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function exportMongodbQuery(request: MongoExportRequest, onProgress: (progress: MongoExportProgress) => void): Promise<MongoExportSummary> {
|
||||
const unlisten: UnlistenFn = await listen<MongoExportProgress>("mongo-export-progress", (event) => {
|
||||
if (event.payload.exportId === request.exportId) {
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "done" || event.payload.status === "error" || event.payload.status === "cancelled") {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
const summary = await invoke<MongoExportSummary>("export_mongodb_query", { request });
|
||||
unlisten();
|
||||
return summary;
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e instanceof BackendErrorException ? e : new BackendErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMongodbExport(exportId: string): Promise<boolean> {
|
||||
return invoke("cancel_mongodb_export", { exportId });
|
||||
}
|
||||
|
||||
// --- Database Export ---
|
||||
export interface DatabaseExportRequest {
|
||||
exportId: string;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export type ImportFileSource = string | File;
|
||||
|
||||
export interface UploadedImportSource {
|
||||
sourceRef: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export function importSourceDisplayName(source: ImportFileSource): string {
|
||||
return typeof source === "string" ? source.split(/[\\/]/).pop() || source : source.name;
|
||||
}
|
||||
|
||||
export function importTextDelimiterForName(name: string): string {
|
||||
return name.toLowerCase().endsWith(".tsv") ? "\\t" : ",";
|
||||
}
|
||||
|
||||
/** Reuse a server-side upload on later previews instead of sending the File again. */
|
||||
export function importPreviewInput(uploaded: UploadedImportSource | null | undefined, source: ImportFileSource): { fileOrPath: ImportFileSource; sourceRef: string | null } {
|
||||
if (uploaded?.sourceRef) return { fileOrPath: uploaded.filePath, sourceRef: uploaded.sourceRef };
|
||||
return { fileOrPath: source, sourceRef: null };
|
||||
}
|
||||
|
||||
export function uploadedImportSourceFromPreview(preview: { sourceRef?: string | null; filePath?: string } | null | undefined): UploadedImportSource | null {
|
||||
if (!preview?.sourceRef || !preview.filePath) return null;
|
||||
return { sourceRef: preview.sourceRef, filePath: preview.filePath };
|
||||
}
|
||||
@@ -35,6 +35,14 @@ export interface TableImportProgressLike {
|
||||
|
||||
export type TableImportWizardStep = "source" | "options" | "mapping" | "review" | "execution";
|
||||
|
||||
export const TABLE_IMPORT_ENCODING_OPTIONS: ReadonlyArray<{ value: TableImportTextEncoding; labelKey: string }> = [
|
||||
{ value: "auto", labelKey: "tableImport.encodingAuto" },
|
||||
{ value: "utf8", labelKey: "tableImport.encodingUtf8" },
|
||||
{ value: "gbk", labelKey: "tableImport.encodingGbk" },
|
||||
{ value: "utf16Le", labelKey: "tableImport.encodingUtf16Le" },
|
||||
{ value: "utf16Be", labelKey: "tableImport.encodingUtf16Be" },
|
||||
];
|
||||
|
||||
export const TABLE_IMPORT_WIZARD_STEPS: TableImportWizardStep[] = ["source", "options", "mapping", "review", "execution"];
|
||||
|
||||
export function formatTableImportElapsed(ms: number): string {
|
||||
|
||||
@@ -552,6 +552,17 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
} | null>(null);
|
||||
const mongoImportSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
} | null>(null);
|
||||
const mongoImportCompleted = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
at: number;
|
||||
} | null>(null);
|
||||
const tableDataGenerateSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
@@ -9144,6 +9155,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
diagramSource,
|
||||
docsSource,
|
||||
tableImportSource,
|
||||
mongoImportSource,
|
||||
mongoImportCompleted,
|
||||
tableDataGenerateSource,
|
||||
fieldLineageSource,
|
||||
databaseSearchSource,
|
||||
|
||||
@@ -2504,13 +2504,139 @@ fn json_update_to_modifications(value: &serde_json::Value) -> Result<UpdateModif
|
||||
}
|
||||
}
|
||||
|
||||
fn json_object_to_document_extended_json(value: &serde_json::Value) -> Result<Document, String> {
|
||||
pub fn json_object_to_document_extended_json(value: &serde_json::Value) -> Result<Document, String> {
|
||||
match Bson::try_from(value.clone()).map_err(|e| e.to_string())? {
|
||||
Bson::Document(doc) => Ok(doc),
|
||||
other => Err(format!("Expected a JSON object, got {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn document_to_canonical_extended_json(document: &Document) -> serde_json::Value {
|
||||
Bson::Document(document.clone()).into_canonical_extjson()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MongoBulkWriteError {
|
||||
pub message: String,
|
||||
pub index: Option<usize>,
|
||||
pub code: Option<i32>,
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
/// What actually happened to a submitted batch. A batch can partly succeed, so the count of
|
||||
/// inserted documents and the per-document rejections are reported together.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MongoInsertOutcome {
|
||||
pub inserted: u64,
|
||||
/// One entry per document the server rejected, `index` pointing into the submitted batch.
|
||||
pub errors: Vec<MongoBulkWriteError>,
|
||||
}
|
||||
|
||||
pub async fn insert_bson_documents(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
documents: Vec<Document>,
|
||||
) -> Result<MongoInsertOutcome, MongoBulkWriteError> {
|
||||
if documents.is_empty() {
|
||||
return Ok(MongoInsertOutcome::default());
|
||||
}
|
||||
let total = documents.len() as u64;
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
// Unordered: one rejected document must not abandon the rest of the batch, and the server
|
||||
// then reports every rejection instead of stopping at the first.
|
||||
match col.insert_many(documents).ordered(false).await {
|
||||
Ok(result) => Ok(MongoInsertOutcome { inserted: result.inserted_ids.len() as u64, errors: Vec::new() }),
|
||||
Err(error) => {
|
||||
let errors = insert_write_errors(&error);
|
||||
if errors.is_empty() {
|
||||
// No per-document detail means the whole batch failed (network, auth, …).
|
||||
return Err(map_insert_many_error(error));
|
||||
}
|
||||
Ok(MongoInsertOutcome { inserted: total.saturating_sub(errors.len() as u64), errors })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-document rejections, sorted by batch index. Empty when the failure was not per-document.
|
||||
fn insert_write_errors(error: &mongodb::error::Error) -> Vec<MongoBulkWriteError> {
|
||||
use mongodb::error::{ErrorKind, WriteFailure};
|
||||
let mut errors = match error.kind.as_ref() {
|
||||
ErrorKind::Write(WriteFailure::WriteError(write_error)) => {
|
||||
vec![write_error_entry(None, write_error.code, &write_error.message)]
|
||||
}
|
||||
ErrorKind::InsertMany(failure) => failure
|
||||
.write_errors
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|error| write_error_entry(Some(error.index), error.code, &error.message))
|
||||
.collect(),
|
||||
ErrorKind::BulkWrite(failure) => failure
|
||||
.write_errors
|
||||
.iter()
|
||||
.map(|(index, error)| write_error_entry(Some(*index), error.code, &error.message))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
errors.sort_by_key(|error| error.index.unwrap_or(0));
|
||||
errors
|
||||
}
|
||||
|
||||
fn write_error_entry(index: Option<usize>, code: i32, message: &str) -> MongoBulkWriteError {
|
||||
MongoBulkWriteError {
|
||||
message: message.to_string(),
|
||||
index,
|
||||
code: Some(code),
|
||||
retryable: is_retryable_mongo_write_code(code),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_insert_many_error(error: mongodb::error::Error) -> MongoBulkWriteError {
|
||||
use mongodb::error::ErrorKind;
|
||||
match error.kind.as_ref() {
|
||||
ErrorKind::Io(_) | ErrorKind::ConnectionPoolCleared { .. } | ErrorKind::ServerSelection { .. } => {
|
||||
MongoBulkWriteError { message: error.to_string(), index: None, code: None, retryable: true }
|
||||
}
|
||||
_ => MongoBulkWriteError { message: error.to_string(), index: None, code: None, retryable: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable_mongo_write_code(code: i32) -> bool {
|
||||
!matches!(code, 11000 | 11001 | 12582)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn for_each_find_document(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
filter: Option<&str>,
|
||||
projection: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
collation: Option<&str>,
|
||||
batch_size: u32,
|
||||
mut on_document: impl FnMut(Document) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
let filter_doc = parse_optional_filter_document(filter)?.unwrap_or_default();
|
||||
let mut find = col.find(filter_doc).batch_size(batch_size);
|
||||
if let Some(projection) = parse_optional_json_document(projection, "projection")? {
|
||||
find = find.projection(projection);
|
||||
}
|
||||
if let Some(sort) = parse_optional_json_document(sort, "sort")? {
|
||||
find = find.sort(sort);
|
||||
}
|
||||
if let Some(collation) = parse_find_collation(collation)? {
|
||||
find = find.collation(collation);
|
||||
}
|
||||
let mut cursor = find.await.map_err(|error| error.to_string())?;
|
||||
while cursor.advance().await.map_err(|error| error.to_string())? {
|
||||
let document = cursor.deserialize_current().map_err(|error| error.to_string())?;
|
||||
on_document(document)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_object_to_document_preserving_existing(
|
||||
value: &serde_json::Value,
|
||||
existing: Option<&Document>,
|
||||
|
||||
@@ -58,6 +58,7 @@ pub mod models;
|
||||
pub mod mongo_oidc;
|
||||
pub mod mongo_ops;
|
||||
pub mod mongo_shell;
|
||||
pub mod mongodb_import_export;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -537,7 +537,7 @@ pub fn csv_value(value: &str) -> serde_json::Value {
|
||||
const IMPORT_ENCODING_READ_CHUNK_BYTES: usize = 16 * 1024;
|
||||
|
||||
// Decodes incrementally and rejects malformed input instead of silently inserting replacement characters.
|
||||
struct StrictTranscodingReader<R> {
|
||||
pub(crate) struct StrictTranscodingReader<R> {
|
||||
reader: R,
|
||||
decoder: encoding_rs::Decoder,
|
||||
encoding: TableImportTextEncoding,
|
||||
@@ -702,7 +702,7 @@ fn auto_detect_text_encoding_from_bytes(bytes: &[u8]) -> Result<(TableImportText
|
||||
Err("Could not detect text encoding; select UTF-8, GBK / GB18030, or UTF-16 manually".to_string())
|
||||
}
|
||||
|
||||
fn resolve_text_encoding_from_bytes(
|
||||
pub(crate) fn resolve_text_encoding_from_bytes(
|
||||
bytes: &[u8],
|
||||
requested: Option<TableImportTextEncoding>,
|
||||
) -> Result<(TableImportTextEncoding, usize), String> {
|
||||
@@ -803,7 +803,7 @@ fn auto_detect_text_encoding_from_file_with_progress(
|
||||
Err("Could not detect text encoding; select UTF-8, GBK / GB18030, or UTF-16 manually".to_string())
|
||||
}
|
||||
|
||||
fn resolve_text_encoding_from_file_with_progress(
|
||||
pub(crate) fn resolve_text_encoding_from_file_with_progress(
|
||||
path: &str,
|
||||
requested: Option<TableImportTextEncoding>,
|
||||
on_progress: impl FnMut(u64),
|
||||
@@ -838,6 +838,16 @@ fn resolve_and_validate_text_encoding_from_file(
|
||||
Ok((encoding, bom_len))
|
||||
}
|
||||
|
||||
pub(crate) fn open_transcoded_text_file(
|
||||
path: &str,
|
||||
encoding: Option<TableImportTextEncoding>,
|
||||
) -> Result<(StrictTranscodingReader<File>, TableImportTextEncoding), String> {
|
||||
let (encoding, bom_len) = resolve_text_encoding_from_file_with_progress(path, encoding, |_| {})?;
|
||||
let mut file = File::open(path).map_err(|error| error.to_string())?;
|
||||
file.seek(SeekFrom::Start(bom_len as u64)).map_err(|error| error.to_string())?;
|
||||
Ok((StrictTranscodingReader::new(file, encoding)?, encoding))
|
||||
}
|
||||
|
||||
fn open_delimited_csv_reader_with_progress(
|
||||
path: &str,
|
||||
source_format: TableImportSourceFormat,
|
||||
|
||||
@@ -941,6 +941,21 @@ async fn main() {
|
||||
.route("/mongo/find-one-and-update", post(routes::mongo::find_one_and_update))
|
||||
.route("/mongo/find-one-and-replace", post(routes::mongo::find_one_and_replace))
|
||||
.route("/mongo/find-one-and-delete", post(routes::mongo::find_one_and_delete))
|
||||
.route(
|
||||
"/mongo/import/preview",
|
||||
post(routes::mongodb_import_export::preview_import).layer(DefaultBodyLimit::max(
|
||||
routes::table_import::import_request_body_limit_for_upload(web_body_limit_bytes()),
|
||||
)),
|
||||
)
|
||||
.route("/mongo/import/preview-source", post(routes::mongodb_import_export::preview_uploaded_import))
|
||||
.route("/mongo/import/source/release", post(routes::mongodb_import_export::release_import_source))
|
||||
.route("/mongo/import/execute", post(routes::mongodb_import_export::execute_import))
|
||||
.route("/mongo/import/progress/{importId}", get(routes::mongodb_import_export::import_progress))
|
||||
.route("/mongo/import/cancel", post(routes::mongodb_import_export::cancel_import))
|
||||
.route("/mongo/export", post(routes::mongodb_import_export::start_export))
|
||||
.route("/mongo/export/progress/{exportId}", get(routes::mongodb_import_export::export_progress))
|
||||
.route("/mongo/export/download/{exportId}", get(routes::mongodb_import_export::export_download))
|
||||
.route("/mongo/export/cancel", post(routes::mongodb_import_export::cancel_export))
|
||||
// History
|
||||
.route("/history", get(routes::history::load_history).delete(routes::history::clear_history))
|
||||
.route("/history/save", post(routes::history::save_history))
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod jdbc;
|
||||
pub mod layout;
|
||||
pub mod mcp_policy;
|
||||
pub mod mongo;
|
||||
pub mod mongodb_import_export;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq;
|
||||
pub mod nacos;
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
use std::path::{Path as StdPath, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::sse::Event;
|
||||
use axum::response::{Response, Sse};
|
||||
use axum::Json;
|
||||
use dbx_core::mongodb_import_export::{
|
||||
self, MongoExportFormat, MongoExportProgress, MongoExportRequest, MongoExportStatus, MongoImportParseOptions,
|
||||
MongoImportPhase, MongoImportPreviewRequest, MongoImportProgress, MongoImportRequest, MongoImportStatus,
|
||||
};
|
||||
use dbx_core::transfer;
|
||||
use futures::stream::Stream;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::routes::export_download::{attachment_content_disposition, export_download_filename};
|
||||
use crate::state::{WebExportFile, WebState};
|
||||
|
||||
const MONGO_IMPORT_PROGRESS_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
fn initial_import_progress(import_id: &str, started_at: Instant) -> MongoImportProgress {
|
||||
MongoImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
phase: MongoImportPhase::Preparing,
|
||||
status: MongoImportStatus::Running,
|
||||
rows_read: 0,
|
||||
rows_inserted: 0,
|
||||
rows_failed: 0,
|
||||
batches_committed: 0,
|
||||
total_rows: None,
|
||||
error_rows: Vec::new(),
|
||||
error_message: None,
|
||||
elapsed_ms: started_at.elapsed().as_millis(),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_import_progress(tx: &tokio::sync::watch::Sender<String>, progress: &MongoImportProgress) {
|
||||
if let Ok(json) = serde_json::to_string(progress) {
|
||||
tx.send_replace(json);
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_import_progress_cleanup(state: Arc<WebState>, import_id: String) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(MONGO_IMPORT_PROGRESS_TTL).await;
|
||||
state.table_import_channels.write().await.remove(&import_id);
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecuteImportWrapper {
|
||||
pub request: MongoImportRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelImportRequest {
|
||||
pub import_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewUploadedImportRequest {
|
||||
pub source_ref: String,
|
||||
pub format: mongodb_import_export::MongoImportFormat,
|
||||
#[serde(default)]
|
||||
pub parse_options: MongoImportParseOptions,
|
||||
#[serde(default)]
|
||||
pub preview_limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReleaseImportSourceRequest {
|
||||
pub source_ref: String,
|
||||
}
|
||||
|
||||
pub async fn preview_import(
|
||||
State(state): State<Arc<WebState>>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let tmp_dir = import_upload_dir(&state.data_dir);
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?;
|
||||
cleanup_expired_import_uploads(&tmp_dir, Duration::from_secs(24 * 60 * 60));
|
||||
|
||||
let mut uploaded_file: Option<(String, PathBuf)> = None;
|
||||
let mut format = None;
|
||||
let mut parse_options = MongoImportParseOptions::default();
|
||||
let mut preview_limit: Option<usize> = None;
|
||||
|
||||
loop {
|
||||
let field = match multipart.next_field().await {
|
||||
Ok(Some(field)) => field,
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
cleanup_pending_upload(&uploaded_file).await;
|
||||
return Err(AppError::from(error.to_string()));
|
||||
}
|
||||
};
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "file" {
|
||||
if uploaded_file.is_some() {
|
||||
cleanup_pending_upload(&uploaded_file).await;
|
||||
return Err(AppError::from("Only one import file may be uploaded".to_string()));
|
||||
}
|
||||
let file_name = field.file_name().unwrap_or("upload.csv").to_string();
|
||||
let source_ref = uuid::Uuid::new_v4().to_string();
|
||||
let file_path = safe_uploaded_import_path(&tmp_dir, &file_name, &source_ref)?;
|
||||
if let Err(error) = write_import_upload(field, &file_path).await {
|
||||
cleanup_uploaded_import_path(&file_path).await;
|
||||
return Err(error);
|
||||
}
|
||||
uploaded_file = Some((source_ref, file_path));
|
||||
} else {
|
||||
let value = match field.text().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
cleanup_pending_upload(&uploaded_file).await;
|
||||
return Err(AppError::from(error.to_string()));
|
||||
}
|
||||
};
|
||||
match name.as_str() {
|
||||
"format" => {
|
||||
format = match serde_json::from_value(serde_json::Value::String(value)) {
|
||||
Ok(format) => Some(format),
|
||||
Err(error) => {
|
||||
cleanup_pending_upload(&uploaded_file).await;
|
||||
return Err(AppError::from(error.to_string()));
|
||||
}
|
||||
};
|
||||
}
|
||||
"parseOptions" => {
|
||||
parse_options = match serde_json::from_str(&value) {
|
||||
Ok(parse_options) => parse_options,
|
||||
Err(error) => {
|
||||
cleanup_pending_upload(&uploaded_file).await;
|
||||
return Err(AppError::from(error.to_string()));
|
||||
}
|
||||
};
|
||||
}
|
||||
"previewLimit" => preview_limit = value.parse::<usize>().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((source_ref, file_path)) = uploaded_file {
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
let format = match format.or_else(|| mongodb_import_export::format_from_path(&file_path_str).ok()) {
|
||||
Some(format) => format,
|
||||
None => {
|
||||
cleanup_uploaded_import_path(&file_path).await;
|
||||
return Err(AppError::from("Unsupported MongoDB import file type".to_string()));
|
||||
}
|
||||
};
|
||||
let preview = mongodb_import_export::preview_mongodb_import_file(&MongoImportPreviewRequest {
|
||||
file_path: file_path_str,
|
||||
source_ref: Some(source_ref),
|
||||
format,
|
||||
parse_options,
|
||||
preview_limit,
|
||||
});
|
||||
let preview = match preview {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => {
|
||||
cleanup_uploaded_import_path(&file_path).await;
|
||||
return Err(AppError::from(error.display_message()));
|
||||
}
|
||||
};
|
||||
return serde_json::to_value(preview).map(Json).map_err(|error| AppError::from(error.to_string()));
|
||||
}
|
||||
|
||||
Err(AppError::from("No file uploaded".to_string()))
|
||||
}
|
||||
|
||||
pub async fn preview_uploaded_import(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(request): Json<PreviewUploadedImportRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let file_path = uploaded_import_path_for_source_ref(&state.data_dir, &request.source_ref)?;
|
||||
let preview = mongodb_import_export::preview_mongodb_import_file(&MongoImportPreviewRequest {
|
||||
file_path: file_path.to_string_lossy().to_string(),
|
||||
source_ref: Some(request.source_ref),
|
||||
format: request.format,
|
||||
parse_options: request.parse_options,
|
||||
preview_limit: request.preview_limit,
|
||||
})
|
||||
.map_err(|error| AppError::from(error.display_message()))?;
|
||||
serde_json::to_value(preview).map(Json).map_err(|error| AppError::from(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn release_import_source(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(request): Json<ReleaseImportSourceRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let released = match uploaded_import_path_for_source_ref(&state.data_dir, &request.source_ref) {
|
||||
Ok(file_path) => tokio::fs::remove_file(file_path).await.is_ok(),
|
||||
Err(_) => false,
|
||||
};
|
||||
Json(serde_json::json!({ "released": released }))
|
||||
}
|
||||
|
||||
async fn write_import_upload(field: axum::extract::multipart::Field<'_>, file_path: &StdPath) -> Result<(), AppError> {
|
||||
write_import_upload_stream(field, file_path, crate::web_body_limit_bytes()).await
|
||||
}
|
||||
|
||||
async fn write_import_upload_stream<S, E>(
|
||||
mut chunks: S,
|
||||
file_path: &StdPath,
|
||||
max_upload_bytes: usize,
|
||||
) -> Result<(), AppError>
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, E>> + Unpin,
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
let mut upload = tokio::fs::File::create(file_path).await.map_err(|error| AppError::from(error.to_string()))?;
|
||||
let mut uploaded_bytes = 0usize;
|
||||
let result = async {
|
||||
while let Some(chunk) = chunks.next().await {
|
||||
let chunk = chunk.map_err(|error| AppError::from(error.to_string()))?;
|
||||
uploaded_bytes = uploaded_bytes.saturating_add(chunk.len());
|
||||
if uploaded_bytes > max_upload_bytes {
|
||||
return Err(AppError::from(format!(
|
||||
"File too large: {uploaded_bytes} bytes received (max {max_upload_bytes} bytes)"
|
||||
)));
|
||||
}
|
||||
upload.write_all(&chunk).await.map_err(|error| AppError::from(error.to_string()))?;
|
||||
}
|
||||
upload.flush().await.map_err(|error| AppError::from(error.to_string()))
|
||||
}
|
||||
.await;
|
||||
drop(upload);
|
||||
if result.is_err() {
|
||||
cleanup_uploaded_import_path(file_path).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn execute_import(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<ExecuteImportWrapper>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let started_at = Instant::now();
|
||||
let mut req = body.request;
|
||||
let file_path = validated_uploaded_import_path(&state.data_dir, &req.file_path)?;
|
||||
req.file_path = file_path.to_string_lossy().to_string();
|
||||
|
||||
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await {
|
||||
cleanup_uploaded_import_source(&req.file_path).await;
|
||||
return Err(AppError::from(format!(
|
||||
"Read-only mode: connection '{name}' has read-only protection enabled. Import blocked."
|
||||
)));
|
||||
}
|
||||
|
||||
let import_id = req.import_id.clone();
|
||||
let initial_progress = serde_json::to_string(&initial_import_progress(&import_id, started_at))
|
||||
.map_err(|error| AppError::from(error.to_string()))?;
|
||||
let (tx, _) = tokio::sync::watch::channel(initial_progress);
|
||||
state.table_import_channels.write().await.insert(import_id.clone(), tx.clone());
|
||||
|
||||
let app = state.app.clone();
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let tx_clone = tx.clone();
|
||||
let _result = mongodb_import_export::import_mongodb_file_core(
|
||||
&app,
|
||||
&req,
|
||||
|id| {
|
||||
let id = id.to_string();
|
||||
Box::pin(async move { transfer::is_cancelled(&id).await })
|
||||
},
|
||||
|progress| send_import_progress(&tx_clone, &progress),
|
||||
)
|
||||
.await;
|
||||
cleanup_uploaded_import_source(&req.file_path).await;
|
||||
schedule_import_progress_cleanup(state_clone, req.import_id.clone());
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "importId": import_id })))
|
||||
}
|
||||
|
||||
pub async fn import_progress(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(import_id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let channels = state.table_import_channels.read().await;
|
||||
let tx = channels.get(&import_id).ok_or_else(|| AppError::from("Import not found".to_string()))?;
|
||||
let rx = tx.subscribe();
|
||||
drop(channels);
|
||||
Ok(crate::sse::sse_from_watch(rx))
|
||||
}
|
||||
|
||||
pub async fn cancel_import(
|
||||
State(_state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelImportRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
transfer::set_cancelled(&req.import_id).await;
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
|
||||
fn import_upload_dir(data_dir: &StdPath) -> PathBuf {
|
||||
data_dir.join("tmp").join("mongo_import")
|
||||
}
|
||||
|
||||
fn safe_uploaded_import_path(tmp_dir: &StdPath, file_name: &str, source_ref: &str) -> Result<PathBuf, AppError> {
|
||||
let base_name = file_name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or("upload.csv").trim();
|
||||
if base_name.is_empty() || base_name == "." || base_name == ".." {
|
||||
return Err(AppError::from("Invalid import file name".to_string()));
|
||||
}
|
||||
Ok(tmp_dir.join(format!("{source_ref}-{base_name}")))
|
||||
}
|
||||
|
||||
fn validated_uploaded_import_path(data_dir: &StdPath, file_path: &str) -> Result<PathBuf, AppError> {
|
||||
let path = PathBuf::from(file_path);
|
||||
if !path.is_absolute() {
|
||||
return Err(AppError::from("Import source path must be absolute".to_string()));
|
||||
}
|
||||
let tmp_dir = import_upload_dir(data_dir).canonicalize().map_err(|e| AppError::from(e.to_string()))?;
|
||||
let canonical_path =
|
||||
path.canonicalize().map_err(|e| AppError::from(format!("Import source is no longer available: {e}")))?;
|
||||
if !canonical_path.starts_with(&tmp_dir) {
|
||||
return Err(AppError::from("Import source must be inside the uploaded MongoDB import directory".to_string()));
|
||||
}
|
||||
Ok(canonical_path)
|
||||
}
|
||||
|
||||
fn uploaded_import_path_for_source_ref(data_dir: &StdPath, source_ref: &str) -> Result<PathBuf, AppError> {
|
||||
uuid::Uuid::parse_str(source_ref).map_err(|_| AppError::from("Invalid import source reference".to_string()))?;
|
||||
let tmp_dir = import_upload_dir(data_dir);
|
||||
let prefix = format!("{source_ref}-");
|
||||
let mut matches = std::fs::read_dir(&tmp_dir)
|
||||
.map_err(|_| AppError::from("Import source is no longer available".to_string()))?
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix))
|
||||
.map(|entry| entry.path());
|
||||
let file_path = matches.next().ok_or_else(|| AppError::from("Import source is no longer available".to_string()))?;
|
||||
if matches.next().is_some() {
|
||||
return Err(AppError::from("Import source reference is ambiguous".to_string()));
|
||||
}
|
||||
validated_uploaded_import_path(data_dir, &file_path.to_string_lossy())
|
||||
}
|
||||
|
||||
fn cleanup_expired_import_uploads(tmp_dir: &StdPath, max_age: Duration) {
|
||||
let Ok(entries) = std::fs::read_dir(tmp_dir) else {
|
||||
return;
|
||||
};
|
||||
let now = SystemTime::now();
|
||||
for entry in entries.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
continue;
|
||||
};
|
||||
if now.duration_since(modified).map(|age| age > max_age).unwrap_or(false) {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_uploaded_import_source(file_path: &str) {
|
||||
let _ = tokio::fs::remove_file(file_path).await;
|
||||
}
|
||||
|
||||
async fn cleanup_uploaded_import_path(file_path: &StdPath) {
|
||||
let _ = tokio::fs::remove_file(file_path).await;
|
||||
}
|
||||
|
||||
async fn cleanup_pending_upload(uploaded_file: &Option<(String, PathBuf)>) {
|
||||
if let Some((_, file_path)) = uploaded_file {
|
||||
cleanup_uploaded_import_path(file_path).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartExportRequest {
|
||||
pub request: MongoExportRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelExportRequest {
|
||||
pub export_id: String,
|
||||
}
|
||||
|
||||
pub async fn start_export(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<StartExportRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut req = body.request;
|
||||
let export_id = req.export_id.clone();
|
||||
let tmp_dir = state.data_dir.join("tmp");
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?;
|
||||
let ext = match req.format {
|
||||
MongoExportFormat::Csv => "csv",
|
||||
MongoExportFormat::Ndjson => "ndjson",
|
||||
};
|
||||
let tmp_file = tmp_dir.join(format!("mongo_export_{export_id}.{ext}"));
|
||||
let file_path = tmp_file.to_string_lossy().to_string();
|
||||
let download_filename = export_download_filename(&req.file_path, &req.collection, ext);
|
||||
req.file_path = file_path.clone();
|
||||
|
||||
state
|
||||
.export_files
|
||||
.write()
|
||||
.await
|
||||
.insert(export_id.clone(), WebExportFile { file_path, download_filename, format: ext.to_string() });
|
||||
|
||||
let tx = {
|
||||
let mut channels = state.sse_channels.write().await;
|
||||
channels.entry(export_id.clone()).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
|
||||
};
|
||||
|
||||
let app = state.app.clone();
|
||||
let state_clone = state.clone();
|
||||
dbx_core::export_runtime::spawn_export_task(async move {
|
||||
let result = mongodb_import_export::export_mongodb_query_core(
|
||||
&app,
|
||||
&req,
|
||||
|id| {
|
||||
let id = id.to_string();
|
||||
Box::pin(async move { transfer::is_cancelled(&id).await })
|
||||
},
|
||||
|progress| {
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
let _ = tokio::fs::remove_file(&req.file_path).await;
|
||||
state_clone.export_files.write().await.remove(&req.export_id);
|
||||
let progress = MongoExportProgress {
|
||||
export_id: req.export_id.clone(),
|
||||
status: MongoExportStatus::Error,
|
||||
documents_read: 0,
|
||||
bytes_written: 0,
|
||||
total_documents: None,
|
||||
error_message: Some(error),
|
||||
elapsed_ms: 0,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
state_clone.remove_sse_channel(&req.export_id).await;
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "exportId": export_id })))
|
||||
}
|
||||
|
||||
pub async fn export_progress(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(export_id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, AppError> {
|
||||
let tx = {
|
||||
let mut channels = state.sse_channels.write().await;
|
||||
channels.entry(export_id).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
|
||||
};
|
||||
let rx = tx.subscribe();
|
||||
Ok(crate::sse::sse_from_channel(rx))
|
||||
}
|
||||
|
||||
pub async fn cancel_export(
|
||||
State(_state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelExportRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
transfer::set_cancelled(&req.export_id).await;
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
|
||||
pub async fn export_download(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(export_id): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let export_file = state
|
||||
.export_files
|
||||
.write()
|
||||
.await
|
||||
.remove(&export_id)
|
||||
.ok_or_else(|| AppError::from("Export file not found".to_string()))?;
|
||||
let data = tokio::fs::read(&export_file.file_path).await.map_err(|e| AppError::from(e.to_string()))?;
|
||||
let _ = tokio::fs::remove_file(&export_file.file_path).await;
|
||||
let content_type = match export_file.format.as_str() {
|
||||
"csv" => "text/csv; charset=utf-8",
|
||||
_ => "application/x-ndjson; charset=utf-8",
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_DISPOSITION, attachment_content_disposition(&export_file.download_filename))
|
||||
.body(Body::from(data))
|
||||
.map_err(|error| AppError::from(error.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source_ref_rejects_path_traversal() {
|
||||
let data_dir = std::env::temp_dir().join(format!("dbx-mongo-import-data-{}", uuid::Uuid::new_v4()));
|
||||
let upload_dir = import_upload_dir(&data_dir);
|
||||
std::fs::create_dir_all(&upload_dir).unwrap();
|
||||
let source_ref = uuid::Uuid::new_v4().to_string();
|
||||
let file_path = upload_dir.join(format!("{source_ref}-users.csv"));
|
||||
std::fs::write(&file_path, b"id,name\n1,Ada\n").unwrap();
|
||||
|
||||
let resolved = uploaded_import_path_for_source_ref(&data_dir, &source_ref)
|
||||
.unwrap_or_else(|error| panic!("failed to resolve uploaded source: {}", error.message));
|
||||
assert_eq!(resolved, file_path.canonicalize().unwrap());
|
||||
assert!(uploaded_import_path_for_source_ref(&data_dir, "../users.csv").is_err());
|
||||
assert!(uploaded_import_path_for_source_ref(&data_dir, &uuid::Uuid::new_v4().to_string()).is_err());
|
||||
let _ = std::fs::remove_dir_all(data_dir);
|
||||
}
|
||||
}
|
||||
@@ -99,14 +99,30 @@ GridFS 工作台用于管理 Bucket 和文件:
|
||||
|
||||
删除 Bucket 会删除其中的文件和 Chunk。生产环境执行前先确认 Bucket 名称和备份。
|
||||
|
||||
## 复制与导出
|
||||
## 导入与导出
|
||||
|
||||
- 复制当前文档、选中文档或字段值
|
||||
- 复制为普通 JSON 或保留类型信息的 Extended JSON
|
||||
- 从表格结果使用多格式复制和导出
|
||||
- MongoDB `find` 查询结果导出会继续读取完整结果范围,而不是只导出当前已显示页(受查询和导出限制约束)
|
||||
Collection 文档提供独立的 CSV/JSON 导入和 CSV/NDJSON 导出,和关系型 Table Import 不是同一条工作流。CSV 每一行生成一个文档,嵌套值保持嵌套,`$oid`、`$date` 等 Extended JSON 类型可以保留。
|
||||
|
||||
导出大型 Collection 前先缩小过滤范围,并关注服务端游标、网络和本地文件大小。
|
||||
### 导入数据
|
||||
|
||||
1. 在集合上右键选择「导入数据」。
|
||||
2. 选择 `.csv`、`.json` 或 `.ndjson` 文件。桌面端读取本地路径,Web 端上传到临时文件。
|
||||
3. 对照预览调整编码、分隔符、表头、空值和类型策略。
|
||||
4. 确认以追加方式写入。重复 `_id` 会停止导入,已提交的批次不会回滚。
|
||||
|
||||
CSV 按 MongoDB Compass:首行表头,嵌套字段用点号路径(`address.city`),数组索引(`tags[0]`、`matrix[0][1]`),ObjectId 写成 24 位十六进制,日期为 ISO-8601,空单元格为 null。导入采样前 1000 行推断列类型;`_id` 的十六进制会还原为 ObjectId,`recognizeObjectIdHex` 可将此规则扩展到其他列。JSON/NDJSON 使用 Extended JSON(`$oid`、`$date`)。全部按字符串会把每个单元格当文本。
|
||||
|
||||
类型保真:`Double` 导出再导入会变为 `Decimal128`(十进制文本无损)。非 `_id` 的 ObjectId 字段会往返为 String,除非打开 `recognizeObjectIdHex`。小的 `Int64` 可能收窄为 `Int32`;ISO 日期格式的字符串会变为 `DateTime`。Extended JSON 类型(`$binary`、`$regularExpression`、`$timestamp`、`$minKey`、`$maxKey`、`$code`)通过 JSON 文本单元格往返。空数组 `[]` 和空对象 `{}` 往返正确。
|
||||
|
||||
首期限制:只支持追加(无清空或 upsert),无按字段类型覆盖,CSV 导出从前 10000 个文档发现字段(之后的字段会被静默丢弃;变化 schema 请改用 NDJSON),无 JSON 数组导出格式,不支持 Excel/GridFS/BSON 二进制导入,失败批次不会自动重试。
|
||||
|
||||
### 导出数据
|
||||
|
||||
- 在集合上右键选择「导出数据」,再选 CSV 或 NDJSON,会像表导出一样直接打开保存文件对话框。
|
||||
- CSV 嵌套对象写为点号列(`address.city`),数组写为索引列(`tags[0]`、`tags[1]`)。NDJSON 每行一个规范 Extended JSON 文档。
|
||||
- 字段数超过 256 的 Collection 请改用 NDJSON。
|
||||
|
||||
如果只需要当前可见选择,仍可从表格复制当前文档、选中文档或字段值为普通 JSON 或 Extended JSON。
|
||||
|
||||
## 安全与兼容边界
|
||||
|
||||
|
||||
@@ -99,14 +99,30 @@ The GridFS workspace manages buckets and files:
|
||||
|
||||
Dropping a bucket removes its files and chunks. Verify the bucket name and backup before doing this in production.
|
||||
|
||||
## Copy and Export
|
||||
## Import and Export
|
||||
|
||||
- Copy the current document, selected documents, or field values
|
||||
- Copy as plain JSON or Extended JSON that preserves type information
|
||||
- Use multi-format copy and export from the grid result
|
||||
- Exporting a MongoDB `find` result continues through the full result scope instead of only the currently displayed page, subject to query and export limits
|
||||
Collection documents have a dedicated CSV/JSON import and CSV/NDJSON export workflow. This is separate from relational Table Import: each CSV row becomes a document, nested values stay nested, and Extended JSON types such as `$oid` and `$date` can be preserved.
|
||||
|
||||
Narrow filters before exporting a large collection and monitor server cursor use, network transfer, and local file size.
|
||||
### Import data
|
||||
|
||||
1. Right-click a collection and choose **Import data**.
|
||||
2. Select a `.csv`, `.json`, or `.ndjson` file. Desktop reads the local path; Web uploads it to a temporary server file.
|
||||
3. Review encoding, delimiter, header, empty-value, and type settings against the preview.
|
||||
4. Confirm append-only insert. Duplicate `_id` values stop the import. Batches that already committed are not rolled back.
|
||||
|
||||
CSV follows MongoDB Compass: a header row, nested fields as dotted paths (`address.city`), array indices (`tags[0]`, `matrix[0][1]`), ObjectId as 24-character hex, dates as ISO-8601, and empty cells as null. Import samples the first 1000 rows to infer column types; `_id` hex is restored as ObjectId, and `recognizeObjectIdHex` extends this to other columns. JSON/NDJSON use Extended JSON (`$oid`, `$date`). All strings keeps every cell as text.
|
||||
|
||||
Type fidelity: `Double` exports and reimports as `Decimal128` (lossless for decimal text). Non-`_id` ObjectId fields round-trip as String unless `recognizeObjectIdHex` is on. Small `Int64` may narrow to `Int32`; ISO-date-looking strings become `DateTime`. Extended JSON types (`$binary`, `$regularExpression`, `$timestamp`, `$minKey`, `$maxKey`, `$code`) round-trip through JSON-text cells. Empty array `[]` and empty object `{}` round-trip correctly.
|
||||
|
||||
First-release limits: append only (no truncate or upsert), no per-field type override, CSV export discovers fields from the first 10000 documents (later fields are silently dropped; use NDJSON for variable schemas), no JSON-array export format, no Excel/GridFS/BSON binary import, and no automatic retry of failed batches.
|
||||
|
||||
### Export data
|
||||
|
||||
- Right-click a collection and choose **Export data**, then CSV or NDJSON. This opens the save-file dialog immediately, the same as table export.
|
||||
- CSV writes nested objects as dotted columns (`address.city`) and arrays as indexed columns (`tags[0]`, `tags[1]`). NDJSON writes one canonical Extended JSON document per line.
|
||||
- Collections with more than 256 distinct fields should use NDJSON.
|
||||
|
||||
Copy the current document, selected documents, or field values as plain JSON or Extended JSON from the grid when you only need the visible selection.
|
||||
|
||||
## Safety and Compatibility Boundaries
|
||||
|
||||
|
||||
@@ -99,14 +99,30 @@ GridFS çalışma alanı bucket'ları ve dosyaları yönetir:
|
||||
|
||||
Bir bucket'ı silmek dosyalarını ve parçalarını kaldırır. Üretimde bunu yapmadan önce bucket adını ve yedeği doğrulayın.
|
||||
|
||||
## Kopyalama ve Dışa Aktarma
|
||||
## İçe ve Dışa Aktarma
|
||||
|
||||
- Geçerli belgeyi, seçili belgeleri ya da alan değerlerini kopyalayın
|
||||
- Düz JSON ya da tür bilgisini koruyan Genişletilmiş JSON olarak kopyalayın
|
||||
- Izgara sonucundan çoklu biçim kopyalama ve dışa aktarma kullanın
|
||||
- Bir MongoDB `find` sonucunu dışa aktarma; yalnızca görüntülenen sayfayla sınırlı kalmaz, sorgu ve dışa aktarma sınırlarına tabi olarak sonucun tamamını kapsar
|
||||
Koleksiyon belgelerinin CSV/JSON içe aktarma ve CSV/NDJSON dışa aktarma iş akışı, ilişkisel Tablo İçe Aktarma’dan ayrıdır. Her CSV satırı bir belge olur, iç içe değerler korunur ve `$oid`, `$date` gibi Extended JSON türleri saklanabilir.
|
||||
|
||||
Büyük bir koleksiyonu dışa aktarmadan önce filtreleri daraltın; sunucu imleç kullanımını, ağ aktarımını ve yerel dosya boyutunu izleyin.
|
||||
### Veri içe aktarma
|
||||
|
||||
1. Bir koleksiyona sağ tıklayıp **Veri içe aktar**’ı seçin.
|
||||
2. Bir `.csv`, `.json` veya `.ndjson` dosyası seçin. Masaüstü yerel yolu okur; Web geçici bir sunucu dosyasına yükler.
|
||||
3. Kodlama, ayırıcı, başlık, boş değer ve tür ayarlarını önizlemeyle doğrulayın.
|
||||
4. Yalnızca ekleme yazımını onaylayın. Yinelenen `_id` içe aktarmayı durdurur. Gönderilmiş toplu işler geri alınmaz.
|
||||
|
||||
CSV, MongoDB Compass ile aynıdır: başlık satırı, noktalı iç içe alanlar (`address.city`), dizi dizinleri (`tags[0]`, `matrix[0][1]`), 24 karakterlik onaltılık ObjectId, ISO-8601 tarih ve boş hücreler null. İçe aktarma sütun türlerini çıkarmak için ilk 1000 satırı örnekler; `_id` onaltılığı ObjectId olarak geri yüklenir ve `recognizeObjectIdHex` bunu diğer sütunlara genişletir. JSON/NDJSON Extended JSON (`$oid`, `$date`) kullanır. Tüm metin kipi her hücreyi metin tutar.
|
||||
|
||||
Tür doğruluğu: `Double` dışa aktarılıp içe aktarıldığında `Decimal128` olur (ondalık metin için kayıpsız). `_id` olmayan ObjectId alanları `recognizeObjectIdHex` açık olmadıkça String olarak gider gelir. Küçük `Int64` değerleri `Int32`'ye daralabilir; ISO tarih biçimli dizeler `DateTime` olur. Extended JSON türleri (`$binary`, `$regularExpression`, `$timestamp`, `$minKey`, `$maxKey`, `$code`) JSON metin hücreleri aracılığıyla gider gelir. Boş dizi `[]` ve boş nesne `{}` doğru şekilde gider gelir.
|
||||
|
||||
İlk sürüm sınırları: yalnızca ekleme (truncate veya upsert yok), alan başına tür geçersiz kılma yok, CSV dışa aktarma ilk 10000 belgeden alanları keşfeder (sonraki alanlar sessizce atılır; değişken şemalar için NDJSON kullanın), JSON dizi dışa aktarma biçimi yok, Excel/GridFS/BSON ikili içe aktarma yok ve başarısız toplu işler otomatik yeniden denenmez.
|
||||
|
||||
### Veri dışa aktarma
|
||||
|
||||
- Bir koleksiyona sağ tıklayıp **Veri dışa aktar**, ardından CSV veya NDJSON seçin. Tablo dışa aktarmada olduğu gibi kaydetme iletişim kutusu hemen açılır.
|
||||
- CSV iç içe nesneleri noktalı sütunlar (`address.city`) ve dizileri dizinli sütunlar (`tags[0]`, `tags[1]`) olarak yazar. NDJSON her satıra bir kanonik Extended JSON belgesi yazar.
|
||||
- 256’dan fazla ayırt edici alanı olan koleksiyonlar NDJSON kullanmalıdır.
|
||||
|
||||
Yalnızca görünen seçim gerekiyorsa, ızgaradan geçerli belgeyi, seçili belgeleri veya alan değerlerini düz JSON ya da Extended JSON olarak kopyalayabilirsiniz.
|
||||
|
||||
## Güvenlik ve Uyumluluk Sınırları
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ description: 通过向导将 CSV、TSV、文本、JSON、Excel 或 SQL 数据导
|
||||
|
||||
表数据导入用于把文件数据写入数据库表。DBX 会先解析和预览文件,再让你确认解析选项、目标表、字段映射和执行方式,而不是选中文件后立即写入。
|
||||
|
||||
MongoDB Collection 不走这条关系型导入流程。请在 Collection 文档页使用 CSV/JSON 导入,参见 [MongoDB 工作区](/cn/docs/mongodb)。
|
||||
|
||||
<Callout type="warn">导入会真实写入数据库。连接的只读保护会阻止导入,但数据库权限仍是最终边界。生产环境使用前请确认目标连接、目标表和备份方案。</Callout>
|
||||
|
||||
## 两种目标方式
|
||||
|
||||
@@ -5,6 +5,8 @@ description: Use a guided workflow to import CSV, TSV, text, JSON, Excel, or SQL
|
||||
|
||||
Table Import writes file data into database tables. DBX parses and previews the source first, then asks you to review parsing options, the target, column mappings, and execution settings before any rows are written.
|
||||
|
||||
MongoDB collections do not use this relational workflow. Import CSV/JSON documents from the collection document page instead; see [MongoDB Workspace](/en/docs/mongodb).
|
||||
|
||||
<Callout type="warn">Import performs real database writes. Connection read-only protection blocks the operation, but database privileges remain the final boundary. Verify the target connection, target table, and backup plan before importing into production.</Callout>
|
||||
|
||||
## Target Modes
|
||||
|
||||
@@ -5,6 +5,8 @@ description: CSV, TSV, metin, JSON, Excel ya da SQL verisini adım adım bir ak
|
||||
|
||||
Tablo İçe Aktarma, dosya verisini veritabanı tablolarına yazar. DBX önce kaynağı ayrıştırıp önizler, ardından herhangi bir satır yazılmadan önce ayrıştırma seçeneklerini, hedefi, sütun eşlemelerini ve çalıştırma ayarlarını gözden geçirmenizi ister.
|
||||
|
||||
MongoDB koleksiyonları bu ilişkisel iş akışını kullanmaz. CSV/JSON belgelerini koleksiyon belge sayfasından içe aktarın; bkz. [MongoDB Çalışma Alanı](/tr/docs/mongodb).
|
||||
|
||||
<Callout type="warn">İçe aktarma gerçek veritabanı yazmaları yapar. Bağlantının salt okunur koruması işlemi engeller, ancak son sınır veritabanı yetkileridir. Üretime aktarmadan önce hedef bağlantıyı, hedef tabloyu ve yedekleme planınızı doğrulayın.</Callout>
|
||||
|
||||
## Hedef Modları
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod mcp;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_http_server;
|
||||
pub mod mongo_cmd;
|
||||
pub mod mongodb_import_export;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mod mq_cmd;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::commands::connection::{ensure_connection_writable, AppState};
|
||||
|
||||
pub use dbx_core::mongodb_import_export::{
|
||||
MongoExportProgress, MongoExportRequest, MongoExportSummary, MongoImportPreview, MongoImportPreviewRequest,
|
||||
MongoImportProgress, MongoImportRequest, MongoImportSummary,
|
||||
};
|
||||
|
||||
static CANCELLED_IMPORTS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn cancelled_imports() -> &'static RwLock<HashSet<String>> {
|
||||
CANCELLED_IMPORTS.get_or_init(|| RwLock::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn emit_import_progress(app: &AppHandle, progress: MongoImportProgress) {
|
||||
let _ = app.emit("mongo-import-progress", progress);
|
||||
}
|
||||
|
||||
fn emit_export_progress(app: &AppHandle, progress: MongoExportProgress) {
|
||||
let _ = app.emit("mongo-export-progress", progress);
|
||||
}
|
||||
|
||||
async fn is_cancelled(import_id: &str) -> bool {
|
||||
cancelled_imports().read().await.contains(import_id)
|
||||
}
|
||||
|
||||
async fn clear_cancelled(import_id: &str) {
|
||||
cancelled_imports().write().await.remove(import_id);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn preview_mongodb_import_file(request: MongoImportPreviewRequest) -> Result<MongoImportPreview, String> {
|
||||
dbx_core::mongodb_import_export::preview_mongodb_import_file_core(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_mongodb_file(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
request: MongoImportRequest,
|
||||
) -> Result<MongoImportSummary, String> {
|
||||
clear_cancelled(&request.import_id).await;
|
||||
ensure_connection_writable(&state, &request.connection_id, "Import").await?;
|
||||
let result = dbx_core::mongodb_import_export::import_mongodb_file_core(
|
||||
&state,
|
||||
&request,
|
||||
|import_id| {
|
||||
let import_id = import_id.to_string();
|
||||
Box::pin(async move { is_cancelled(&import_id).await })
|
||||
},
|
||||
|progress| emit_import_progress(&app, progress),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.display_message());
|
||||
clear_cancelled(&request.import_id).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_mongodb_import(import_id: String) -> Result<bool, String> {
|
||||
cancelled_imports().write().await.insert(import_id);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_mongodb_query(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
request: MongoExportRequest,
|
||||
) -> Result<MongoExportSummary, String> {
|
||||
dbx_core::mongodb_import_export::export_mongodb_query_core(
|
||||
&state,
|
||||
&request,
|
||||
|export_id| {
|
||||
let export_id = export_id.to_string();
|
||||
Box::pin(async move { dbx_core::transfer::is_cancelled(&export_id).await })
|
||||
},
|
||||
|progress| emit_export_progress(&app, progress),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_mongodb_export(export_id: String) -> Result<bool, String> {
|
||||
dbx_core::transfer::set_cancelled(&export_id).await;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1981,6 +1981,11 @@ pub fn run() {
|
||||
commands::table_import::preview_table_import_file,
|
||||
commands::table_import::import_table_file,
|
||||
commands::table_import::cancel_table_import,
|
||||
commands::mongodb_import_export::preview_mongodb_import_file,
|
||||
commands::mongodb_import_export::import_mongodb_file,
|
||||
commands::mongodb_import_export::cancel_mongodb_import,
|
||||
commands::mongodb_import_export::export_mongodb_query,
|
||||
commands::mongodb_import_export::cancel_mongodb_export,
|
||||
commands::redis_cmd::redis_list_databases,
|
||||
commands::redis_cmd::redis_scan_keys,
|
||||
commands::redis_cmd::redis_scan_keys_batch,
|
||||
|
||||
Reference in New Issue
Block a user