feat(mongodb): add Compass-style collection import/export

This commit is contained in:
onenewcode authored and GitHub committed 2026-09-12 21:59:29 +08:00
1 parent bf4874906c
commit 8ff56b4837
47 files changed
+5626 -52

No files matched your search

@@ -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({
activeNode,
connectionStore,
queryStore,
settingsStore,
tableChildObjectName: tableChildDropObjectName,
acceptedSelectionIds: () => acceptedSelectionIds,
});
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 });
@@ -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");
});
});