feat(mongo): add collection clone

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-10 15:53:00 +08:00
1 parent e80942de17
commit 5fe3b5b435
34 files changed
+1217 -21

No files matched your search

@@ -96,6 +96,11 @@ const {
renameMongoCollectionPreview,
renameMongoCollectionLoading,
confirmRenameMongoCollection,
showCloneMongoCollectionDialog,
cloneMongoCollectionName,
cloneMongoCollectionError,
cloneMongoCollectionLoading,
confirmCloneMongoCollection,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
@@ -176,6 +181,7 @@ watch(
showCreateNacosNamespaceDialog,
showEditNacosNamespaceDialog,
showRenameMongoCollectionDialog,
showCloneMongoCollectionDialog,
showCreateMongoIndexDialog,
showRedisDatabaseAliasDialog,
showCreateSchemaDialog,
@@ -277,6 +283,23 @@ watch(
</DialogContent>
</Dialog>
<Dialog v-model:open="showCloneMongoCollectionDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.cloneCollectionTitle") }}</DialogTitle>
</DialogHeader>
<Input v-model="cloneMongoCollectionName" :placeholder="t('contextMenu.cloneCollectionNamePlaceholder')" :disabled="cloneMongoCollectionLoading" @keydown.enter.prevent="confirmCloneMongoCollection" />
<p v-if="cloneMongoCollectionError" class="text-sm text-destructive">{{ cloneMongoCollectionError }}</p>
<DialogFooter>
<Button variant="outline" :disabled="cloneMongoCollectionLoading" @click="showCloneMongoCollectionDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="cloneMongoCollectionLoading || !cloneMongoCollectionName || cloneMongoCollectionName === node.label" @click="confirmCloneMongoCollection">
<Loader2 v-if="cloneMongoCollectionLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateMongoIndexDialog">
<DialogContent class="min-w-0 sm:max-w-[600px]">
<DialogHeader>
@@ -398,6 +398,7 @@ const {
canDropMongoDatabase,
canDropMongoCollection,
canRenameMongoCollection,
canCloneMongoCollection,
prepareRenameMongoCollectionDialog,
confirmRenameMongoCollection,
showRenameMongoCollectionDialog,
@@ -405,6 +406,12 @@ const {
renameMongoCollectionError,
renameMongoCollectionPreview,
renameMongoCollectionLoading,
prepareCloneMongoCollectionDialog,
confirmCloneMongoCollection,
showCloneMongoCollectionDialog,
cloneMongoCollectionName,
cloneMongoCollectionError,
cloneMongoCollectionLoading,
mongoIndexNameForNode,
canDropMongoIndexNode,
canDropMongoIndex,
@@ -1007,6 +1014,12 @@ function openRenameMongoCollectionDialog() {
prepareRenameMongoCollectionDialog();
}
function openCloneMongoCollectionDialog() {
claimTreeItemDialogOwnership();
routeTreeItemDialogController();
prepareCloneMongoCollectionDialog();
}
function openCreateMongoIndexDialog() {
claimTreeItemDialogOwnership();
routeTreeItemDialogController();
@@ -3810,6 +3823,11 @@ function databaseSpecificDialogCapabilities() {
renameMongoCollectionPreview,
renameMongoCollectionLoading,
confirmRenameMongoCollection,
showCloneMongoCollectionDialog,
cloneMongoCollectionName,
cloneMongoCollectionError,
cloneMongoCollectionLoading,
confirmCloneMongoCollection,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
@@ -4383,6 +4401,9 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
shortcut: shortcutRename,
});
}
if (canCloneMongoCollection.value) {
items.push({ label: t("contextMenu.cloneCollection"), action: openCloneMongoCollectionDialog, icon: CopyPlus });
}
if (canDropMongoCollection.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
@@ -93,6 +93,10 @@ export const renameMongoCollectionName = ref("");
export const renameMongoCollectionError = ref("");
export const renameMongoCollectionPreview = ref("");
export const renameMongoCollectionLoading = ref(false);
export const showCloneMongoCollectionDialog = ref(false);
export const cloneMongoCollectionName = ref("");
export const cloneMongoCollectionError = ref("");
export const cloneMongoCollectionLoading = ref(false);
export const showDropMongoIndexConfirm = ref(false);
export const dropMongoIndexLoading = ref(false);
export const showDropAllMongoIndexesConfirm = ref(false);
@@ -152,6 +156,7 @@ const openFlags = [
showDropDatabaseConfirm,
showDropMongoCollectionConfirm,
showRenameMongoCollectionDialog,
showCloneMongoCollectionDialog,
showDropMongoIndexConfirm,
showDropAllMongoIndexesConfirm,
showCreateMongoIndexDialog,
@@ -176,6 +181,9 @@ export function resetSidebarTreeDialogState() {
createDatabaseAuthorizationApplying.value = false;
redisDatabaseAliasInput.value = "";
redisDatabaseAliasSaving.value = false;
cloneMongoCollectionName.value = "";
cloneMongoCollectionError.value = "";
cloneMongoCollectionLoading.value = false;
resetMongoCreateIndexForm();
sidebarTreeDialogOwner.value = null;
sidebarDangerTarget.value = null;
@@ -2,7 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { shallowRef } from "vue";
import type { TreeNode } from "@/types/database";
import { mongoCreateIndexError, mongoCreateIndexForm, resetMongoCreateIndexForm, sidebarDangerTarget, sidebarFormTarget, showCreateMongoIndexDialog, showDropAllMongoIndexesConfirm, showDropMongoCollectionConfirm } from "@/components/sidebar/sidebarTreeDialogState";
import {
cloneMongoCollectionError,
cloneMongoCollectionLoading,
cloneMongoCollectionName,
mongoCreateIndexError,
mongoCreateIndexForm,
resetMongoCreateIndexForm,
sidebarDangerTarget,
sidebarFormTarget,
showCloneMongoCollectionDialog,
showCreateMongoIndexDialog,
showDropAllMongoIndexesConfirm,
showDropMongoCollectionConfirm,
} from "@/components/sidebar/sidebarTreeDialogState";
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
@@ -13,6 +26,7 @@ const mocks = vi.hoisted(() => ({
loadMongoDatabases: vi.fn().mockResolvedValue(undefined),
removeTreeNode: vi.fn(),
mongoCreateIndex: vi.fn().mockResolvedValue({ name: "email_1" }),
mongoCloneCollection: vi.fn().mockResolvedValue({ documents_copied: 2, indexes_copied: 1 }),
mongoDropCollection: vi.fn().mockResolvedValue(undefined),
mongoDropDatabase: vi.fn().mockResolvedValue(undefined),
mongoDropIndexes: vi.fn().mockResolvedValue({ dropped_names: ["email_1"], affected_rows: 1 }),
@@ -35,6 +49,7 @@ vi.mock("@/stores/connectionStore", () => ({
vi.mock("@/lib/backend/api", () => ({
mongoCreateIndex: (...args: unknown[]) => mocks.mongoCreateIndex(...args),
mongoCloneCollection: (...args: unknown[]) => mocks.mongoCloneCollection(...args),
mongoDropCollection: (...args: unknown[]) => mocks.mongoDropCollection(...args),
mongoDropDatabase: (...args: unknown[]) => mocks.mongoDropDatabase(...args),
mongoDropIndexes: (...args: unknown[]) => mocks.mongoDropIndexes(...args),
@@ -142,15 +157,20 @@ describe("MongoDB sidebar mutation runtime", () => {
mocks.loadMongoCollections.mockResolvedValue(undefined);
mocks.loadMongoDatabases.mockResolvedValue(undefined);
mocks.mongoCreateIndex.mockResolvedValue({ name: "email_1" });
mocks.mongoCloneCollection.mockResolvedValue({ documents_copied: 2, indexes_copied: 1 });
mocks.mongoDropCollection.mockResolvedValue(undefined);
mocks.mongoDropDatabase.mockResolvedValue(undefined);
mocks.mongoDropIndexes.mockResolvedValue({ dropped_names: ["email_1"], affected_rows: 1 });
sidebarDangerTarget.value = null;
sidebarFormTarget.value = null;
showCreateMongoIndexDialog.value = false;
showCloneMongoCollectionDialog.value = false;
showDropAllMongoIndexesConfirm.value = false;
showDropMongoCollectionConfirm.value = false;
resetMongoCreateIndexForm();
cloneMongoCollectionName.value = "";
cloneMongoCollectionError.value = "";
cloneMongoCollectionLoading.value = false;
});
it("keeps Legacy MongoDB mutations available while limiting index actions to the Indexes group", () => {
@@ -174,6 +194,7 @@ describe("MongoDB sidebar mutation runtime", () => {
expect(feature.canDropMongoCollection.value).toBe(true);
expect(feature.canDropAllMongoIndexes.value).toBe(false);
expect(feature.canRenameMongoCollection.value).toBe(false);
expect(feature.canCloneMongoCollection.value).toBe(true);
expect(feature.canCreateMongoIndex.value).toBe(false);
activeNode.value = mongoIndexesGroupNode();
expect(feature.canDropAllMongoIndexes.value).toBe(true);
@@ -203,6 +224,7 @@ describe("MongoDB sidebar mutation runtime", () => {
});
expect(feature.canDropMongoCollection.value).toBe(true);
expect(feature.canCloneMongoCollection.value).toBe(false);
expect(feature.canDropAllMongoIndexes.value).toBe(false);
activeNode.value = mongoIndexesGroupNode("view");
expect(feature.canDropAllMongoIndexes.value).toBe(false);
@@ -219,6 +241,39 @@ describe("MongoDB sidebar mutation runtime", () => {
expect(feature.canDropAllMongoIndexes.value).toBe(false);
});
it("clones a regular MongoDB collection through the Legacy Agent and refreshes the sidebar", async () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
expect(feature.canCloneMongoCollection.value).toBe(true);
feature.prepareCloneMongoCollectionDialog();
expect(cloneMongoCollectionName.value).toBe("users_copy");
await feature.confirmCloneMongoCollection();
expect(mocks.ensureConnected).toHaveBeenCalledWith("conn-1");
expect(mocks.mongoCloneCollection).toHaveBeenCalledWith("conn-1", "app", "users", "users_copy");
expect(mocks.loadMongoCollections).toHaveBeenCalledWith("conn-1", "app");
expect(showCloneMongoCollectionDialog.value).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith('contextMenu.cloneCollectionSuccess:{"name":"users_copy","documents":2,"indexes":1}', 3000);
});
it("refreshes the collection list when cloning fails after a target may have been created", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
mocks.mongoCloneCollection.mockRejectedValue(new Error("index build failed"));
feature.prepareCloneMongoCollectionDialog();
await feature.confirmCloneMongoCollection();
expect(mocks.loadMongoCollections).toHaveBeenCalledWith("conn-1", "app");
expect(showCloneMongoCollectionDialog.value).toBe(true);
expect(cloneMongoCollectionError.value).toContain("index build failed");
});
it("creates an index from the shared sidebar dialog state", async () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const node = mongoIndexesGroupNode();
@@ -10,9 +10,11 @@ import { findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import {
MONGO_INDEX_KEY_TYPES,
buildMongoCreateIndexRequest,
isCloneableMongoCollection,
isProtectedMongoIndex,
isRenamableMongoCollection,
mongoCollectionKindFromNode,
mongoCloneCollectionPreview,
mongoCreateIndexPreview,
mongoDropAllIndexesPreview,
mongoDropCollectionPreview,
@@ -54,6 +56,10 @@ import {
renameMongoCollectionError,
renameMongoCollectionPreview,
renameMongoCollectionLoading,
showCloneMongoCollectionDialog,
cloneMongoCollectionName,
cloneMongoCollectionError,
cloneMongoCollectionLoading,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
@@ -102,8 +108,13 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
return canMutateMongoCollectionNode(node) && usesNativeMongoDriver(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node));
}
function canCloneMongoCollectionNode(node: TreeNode): boolean {
return canMutateMongoCollectionNode(node) && isCloneableMongoCollection(node.label, mongoCollectionKindFromNode(node));
}
const canDropMongoCollection = computed(() => canMutateMongoCollectionNode(activeNode.value));
const canRenameMongoCollection = computed(() => canRenameMongoCollectionNode(activeNode.value));
const canCloneMongoCollection = computed(() => canCloneMongoCollectionNode(activeNode.value));
function toastMutationError(error: unknown) {
toast(t("contextMenu.tableOperationFailed", { message: errorMessage(error) }), 5000);
@@ -163,6 +174,55 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
});
}
function prepareCloneMongoCollectionDialog() {
cloneMongoCollectionName.value = `${activeNode.value.label}_copy`;
cloneMongoCollectionError.value = "";
cloneMongoCollectionLoading.value = false;
showCloneMongoCollectionDialog.value = true;
}
async function confirmCloneMongoCollection() {
const node = sidebarFormTarget.value ?? activeNode.value;
const connectionId = node.connectionId;
const database = node.database;
const targetName = cloneMongoCollectionName.value;
if (!canCloneMongoCollectionNode(node) || !connectionId || !database || !targetName || targetName === node.label) return;
const sourceName = node.label;
cloneMongoCollectionError.value = "";
await runMongoSidebarMutation({
connection: connectionStore.getConfig(connectionId),
database,
reviewText: mongoCloneCollectionPreview(database, sourceName, targetName),
source: t("production.sourceSidebar"),
loading: cloneMongoCollectionLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
try {
return await api.mongoCloneCollection(connectionId, database, sourceName, targetName);
} finally {
// A failed write can still leave a target collection with partial data;
// refresh so it is visible and can be inspected or removed explicitly.
await connectionStore.loadMongoCollections(connectionId, database);
}
},
onSuccess: (result) => {
toast(
t("contextMenu.cloneCollectionSuccess", {
name: targetName,
documents: result.documents_copied,
indexes: result.indexes_copied,
}),
3000,
);
showCloneMongoCollectionDialog.value = false;
},
onError: (error) => {
cloneMongoCollectionError.value = translateBackendError(t, errorMessage(error));
},
});
}
function mongoIndexNameForNode(node: TreeNode): string {
if (node.type !== "index") return "";
return node.meta && "name" in node.meta ? node.meta.name : node.label.replace(/\s+\(.+\)$/, "");
@@ -558,6 +618,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
canDropMongoDatabase,
canDropMongoCollection,
canRenameMongoCollection,
canCloneMongoCollection,
prepareRenameMongoCollectionDialog,
confirmRenameMongoCollection,
showRenameMongoCollectionDialog,
@@ -565,6 +626,12 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
renameMongoCollectionError,
renameMongoCollectionPreview,
renameMongoCollectionLoading,
prepareCloneMongoCollectionDialog,
confirmCloneMongoCollection,
showCloneMongoCollectionDialog,
cloneMongoCollectionName,
cloneMongoCollectionError,
cloneMongoCollectionLoading,
mongoIndexNameForNode,
canDropMongoIndexNode,
canDropMongoIndex,
+4
View File
@@ -2608,6 +2608,10 @@ export default {
attachSqliteDatabaseDesktopOnly: "Attaching SQLite database files is only available in the desktop app",
dropDatabaseSuccess: 'Database "{name}" dropped',
dropCollection: "Drop Collection",
cloneCollection: "Clone Collection",
cloneCollectionTitle: "Clone as New Collection",
cloneCollectionNamePlaceholder: "New collection name",
cloneCollectionSuccess: 'Collection "{name}" cloned with {documents} documents and {indexes} indexes',
confirmDropCollectionTitle: "Drop Collection",
confirmDropCollectionMessage: 'Are you sure you want to drop collection "{name}"? This will permanently delete the collection and all its documents.',
dropCollectionSuccess: 'Collection "{name}" dropped',
+4
View File
@@ -2538,6 +2538,10 @@ export default withEnglishFallback({
attachSqliteDatabaseDesktopOnly: "Adjuntar archivos de base de datos SQLite solo está disponible en la app de escritorio",
dropDatabaseSuccess: 'Base de datos "{name}" eliminada',
dropCollection: "Eliminar colección",
cloneCollection: "Clonar colección",
cloneCollectionTitle: "Clonar como nueva colección",
cloneCollectionNamePlaceholder: "Nombre de la nueva colección",
cloneCollectionSuccess: 'Colección "{name}" clonada con {documents} documentos y {indexes} índices',
confirmDropCollectionTitle: "Eliminar colección",
confirmDropCollectionMessage: '¿Estás seguro de que deseas eliminar la colección "{name}"? Esto borrará permanentemente la colección y todos sus documentos.',
dropCollectionSuccess: 'Colección "{name}" eliminada',
+4
View File
@@ -2536,6 +2536,10 @@ export default withEnglishFallback({
attachSqliteDatabaseDesktopOnly: "Il collegamento di file di database SQLite è disponibile solo nell'applicazione desktop",
dropDatabaseSuccess: 'Database "{name}" eliminato',
dropCollection: "Elimina Collection",
cloneCollection: "Clona raccolta",
cloneCollectionTitle: "Clona come nuova raccolta",
cloneCollectionNamePlaceholder: "Nome della nuova raccolta",
cloneCollectionSuccess: 'Raccolta "{name}" clonata con {documents} documenti e {indexes} indici',
confirmDropCollectionTitle: "Elimina Collection",
confirmDropCollectionMessage: 'Sei sicuro di voler eliminare la collection "{name}"? Questa operazione eliminerà permanentemente la collection e tutti i suoi documenti.',
dropCollectionSuccess: 'Collection "{name}" eliminata',
+4
View File
@@ -2559,6 +2559,10 @@ export default withEnglishFallback({
attachSqliteDatabaseDesktopOnly: "SQLiteデータベースファイルのアタッチはデスクトップアプリでのみ利用可能です",
dropDatabaseSuccess: "データベース「{name}」を削除しました",
dropCollection: "コレクションを削除",
cloneCollection: "コレクションを複製",
cloneCollectionTitle: "新しいコレクションとして複製",
cloneCollectionNamePlaceholder: "新しいコレクション名",
cloneCollectionSuccess: "コレクション「{name}」を複製しました(ドキュメント {documents} 件、インデックス {indexes} 件)",
confirmDropCollectionTitle: "コレクションを削除",
confirmDropCollectionMessage: "本当にコレクション「{name}」を削除しますか?コレクションとすべてのドキュメントが永久に削除されます。",
dropCollectionSuccess: "コレクション「{name}」を削除しました",
+4
View File
@@ -2459,6 +2459,10 @@ export default withEnglishFallback({
attachSqliteDatabaseDesktopOnly: "SQLite 데이터베이스 파일 연결은 데스크톱 앱에서만 사용할 수 있습니다",
dropDatabaseSuccess: '데이터베이스 "{name}" 삭제됨',
dropCollection: "컬렉션 삭제",
cloneCollection: "컬렉션 복제",
cloneCollectionTitle: "새 컬렉션으로 복제",
cloneCollectionNamePlaceholder: "새 컬렉션 이름",
cloneCollectionSuccess: '컬렉션 "{name}"을(를) 복제했습니다(문서 {documents}개, 인덱스 {indexes}개)',
confirmDropCollectionTitle: "컬렉션 삭제",
confirmDropCollectionMessage: '컬렉션 "{name}"을(를) 삭제하시겠습니까? 컬렉션과 모든 문서가 영구적으로 삭제됩니다.',
dropCollectionSuccess: '컬렉션 "{name}" 삭제됨',
+4
View File
@@ -2538,6 +2538,10 @@ export default withEnglishFallback({
attachSqliteDatabaseDesktopOnly: "Anexar arquivos de banco de dados SQLite só está disponível no aplicativo desktop",
dropDatabaseSuccess: 'Banco de dados "{name}" removido',
dropCollection: "Remover coleção",
cloneCollection: "Clonar coleção",
cloneCollectionTitle: "Clonar como nova coleção",
cloneCollectionNamePlaceholder: "Nome da nova coleção",
cloneCollectionSuccess: 'Coleção "{name}" clonada com {documents} documentos e {indexes} índices',
confirmDropCollectionTitle: "Remover coleção",
confirmDropCollectionMessage: 'Tem certeza de que deseja remover a coleção "{name}"? Isso excluirá permanentemente a coleção e todos os seus documentos.',
dropCollectionSuccess: 'Coleção "{name}" removida',
+4
View File
@@ -2608,6 +2608,10 @@ export default withEnglishFallback({
attachSqliteDatabaseDesktopOnly: "附加 SQLite 数据库文件仅支持桌面端",
dropDatabaseSuccess: "数据库「{name}」已删除",
dropCollection: "删除集合",
cloneCollection: "克隆集合",
cloneCollectionTitle: "克隆为新集合",
cloneCollectionNamePlaceholder: "新集合名",
cloneCollectionSuccess: "已克隆集合「{name}」,共 {documents} 个文档和 {indexes} 个索引",
confirmDropCollectionTitle: "删除集合",
confirmDropCollectionMessage: "确定要删除集合「{name}」吗?这将永久删除该集合及其所有文档。",
dropCollectionSuccess: "集合「{name}」已删除",
+4
View File
@@ -2537,6 +2537,10 @@ export default withEnglishFallback({
attachSqliteDatabaseDesktopOnly: "附加 SQLite 資料庫檔案僅支援桌面端",
dropDatabaseSuccess: "資料庫「{name}」已刪除",
dropCollection: "刪除集合",
cloneCollection: "複製集合",
cloneCollectionTitle: "複製為新集合",
cloneCollectionNamePlaceholder: "新集合名稱",
cloneCollectionSuccess: "已複製集合「{name}」,共 {documents} 個文件和 {indexes} 個索引",
confirmDropCollectionTitle: "刪除集合",
confirmDropCollectionMessage: "確定要刪除集合「{name}」嗎?這將永久刪除該集合及其所有文件。",
dropCollectionSuccess: "集合「{name}」已刪除",
+1
View File
@@ -570,6 +570,7 @@ export const mongoCreateDatabase = forward("mongoCreateDatabase");
export const mongoDropDatabase = forward("mongoDropDatabase");
export const mongoDropCollection = forward("mongoDropCollection");
export const mongoRenameCollection = forward("mongoRenameCollection");
export const mongoCloneCollection = forward("mongoCloneCollection");
export const documentFindDocuments = forward("documentFindDocuments");
export const elasticsearchCountDocuments = forward("elasticsearchCountDocuments");
export const mongoFindDocuments = forward("mongoFindDocuments");
+10
View File
@@ -99,6 +99,7 @@ import type {
DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
MongoCloneCollectionResult,
MongoDropIndexesResult,
MongoGridFsBucketInfo,
HistoryEntry,
@@ -3005,6 +3006,15 @@ export async function mongoRenameCollection(connectionId: string, database: stri
});
}
export async function mongoCloneCollection(connectionId: string, database: string, sourceCollection: string, targetCollection: string): Promise<MongoCloneCollectionResult> {
return post("/api/mongo/clone-collection", {
connectionId,
database,
sourceCollection,
targetCollection,
});
}
export async function elasticsearchListIndices(connectionId: string): Promise<string[]> {
const collections = await documentListCollections(connectionId, "default");
return collections.map((c) => c.name);
+14
View File
@@ -2888,6 +2888,11 @@ export interface MongoDropIndexesResult {
failures?: MongoDropIndexFailure[];
}
export interface MongoCloneCollectionResult {
documents_copied: number;
indexes_copied: number;
}
export interface MongoGridFsFileInfo {
id: string;
filename?: string;
@@ -2955,6 +2960,15 @@ export async function mongoRenameCollection(connectionId: string, database: stri
});
}
export async function mongoCloneCollection(connectionId: string, database: string, sourceCollection: string, targetCollection: string): Promise<MongoCloneCollectionResult> {
return invoke("mongo_clone_collection", {
connectionId,
database,
sourceCollection,
targetCollection,
});
}
export async function elasticsearchListIndices(connectionId: string): Promise<string[]> {
const collections = await documentListCollections(connectionId, "default");
return collections.map((c) => c.name);
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import {
buildMongoCreateIndexRequest,
isCloneableMongoCollection,
isProtectedMongoIndex,
isRenamableMongoCollection,
mongoCollectionKindFromNode,
mongoCollectionTableTypeFromNode,
mongoCloneCollectionPreview,
mongoCreateIndexPreview,
mongoDropCollectionPreview,
mongoDropAllIndexesPreview,
@@ -32,6 +34,15 @@ describe("isRenamableMongoCollection", () => {
});
});
describe("isCloneableMongoCollection", () => {
it("allows ordinary collections only", () => {
expect(isCloneableMongoCollection("users")).toBe(true);
expect(isCloneableMongoCollection("report_view", "view")).toBe(false);
expect(isCloneableMongoCollection("metrics", "timeseries")).toBe(false);
expect(isCloneableMongoCollection("system.users")).toBe(false);
});
});
describe("mongoCollectionKindFromNode", () => {
it("reads collectionKind from node meta without using SQL tableType", () => {
expect(mongoCollectionKindFromNode({ meta: { collectionKind: "view" } })).toBe("view");
@@ -69,6 +80,15 @@ describe("mongo shell previews", () => {
expect(mongoRenameCollectionPreview("app", " users ", " renamed ")).toBe('db.getSiblingDB("app").getCollection(" users ").renameCollection(" renamed ")');
});
it("describes the version-compatible clone primitives", () => {
expect(mongoCloneCollectionPreview("app", " users ", " users_backup ")).toBe(
"// DBX copies collection options, documents, and non-_id indexes.\n" +
'db.getSiblingDB("app").createCollection(" users_backup ", /* source options */);\n' +
'db.getSiblingDB("app").getCollection(" users ").find({}).forEach(function (document) { db.getSiblingDB("app").getCollection(" users_backup ").insertOne(document); });\n' +
"// Recreate source indexes except the target's automatic _id index.",
);
});
it("builds drop previews with database scope", () => {
expect(mongoDropCollectionPreview("app", "users")).toBe('db.getSiblingDB("app").getCollection("users").drop()');
expect(mongoDropIndexPreview("app", "users", "idx_name")).toBe('db.getSiblingDB("app").getCollection("users").dropIndex("idx_name")');
@@ -38,6 +38,11 @@ export function isRenamableMongoCollection(name: string, kind: MongoCollectionKi
return kind === "collection" && !name.startsWith("system.");
}
/** Only ordinary collections have transferable options, documents, and indexes. */
export function isCloneableMongoCollection(name: string, kind: MongoCollectionKind = "collection"): boolean {
return kind === "collection" && !name.startsWith("system.");
}
export function mongoCollectionKindFromNode(node: Pick<TreeNode, "meta">): MongoCollectionKind {
const meta = node.meta;
if (meta && "collectionKind" in meta && meta.collectionKind) {
@@ -62,6 +67,22 @@ export function mongoRenameCollectionPreview(database: string, oldName: string,
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(oldName)}).renameCollection(${JSON.stringify(newName)})`;
}
/**
* DBX executes these stable primitives in the backend instead of MongoDB's
* deprecated clone commands, which vary across server generations.
*/
export function mongoCloneCollectionPreview(database: string, sourceName: string, targetName: string): string {
const db = `db.getSiblingDB(${JSON.stringify(database)})`;
const source = `${db}.getCollection(${JSON.stringify(sourceName)})`;
const target = `${db}.getCollection(${JSON.stringify(targetName)})`;
return [
`// DBX copies collection options, documents, and non-_id indexes.`,
`${db}.createCollection(${JSON.stringify(targetName)}, /* source options */);`,
`${source}.find({}).forEach(function (document) { ${target}.insertOne(document); });`,
`// Recreate source indexes except the target's automatic _id index.`,
].join("\n");
}
export function mongoDropCollectionPreview(database: string, collection: string): string {
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).drop()`;
}