feat(mongodb): add Compass-style collection import/export
This commit is contained in:
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;
|
||||
|
||||
+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,
|
||||
|
||||
Reference in new issue
Block a user