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

@@ -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;
@@ -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,