feat(mongo): add collection clone
This commit is contained in:
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;
|
||||
|
||||
Vendored
+56
-1
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2559,6 +2559,10 @@ export default withEnglishFallback({
|
||||
attachSqliteDatabaseDesktopOnly: "SQLiteデータベースファイルのアタッチはデスクトップアプリでのみ利用可能です",
|
||||
dropDatabaseSuccess: "データベース「{name}」を削除しました",
|
||||
dropCollection: "コレクションを削除",
|
||||
cloneCollection: "コレクションを複製",
|
||||
cloneCollectionTitle: "新しいコレクションとして複製",
|
||||
cloneCollectionNamePlaceholder: "新しいコレクション名",
|
||||
cloneCollectionSuccess: "コレクション「{name}」を複製しました(ドキュメント {documents} 件、インデックス {indexes} 件)",
|
||||
confirmDropCollectionTitle: "コレクションを削除",
|
||||
confirmDropCollectionMessage: "本当にコレクション「{name}」を削除しますか?コレクションとすべてのドキュメントが永久に削除されます。",
|
||||
dropCollectionSuccess: "コレクション「{name}」を削除しました",
|
||||
|
||||
@@ -2459,6 +2459,10 @@ export default withEnglishFallback({
|
||||
attachSqliteDatabaseDesktopOnly: "SQLite 데이터베이스 파일 연결은 데스크톱 앱에서만 사용할 수 있습니다",
|
||||
dropDatabaseSuccess: '데이터베이스 "{name}" 삭제됨',
|
||||
dropCollection: "컬렉션 삭제",
|
||||
cloneCollection: "컬렉션 복제",
|
||||
cloneCollectionTitle: "새 컬렉션으로 복제",
|
||||
cloneCollectionNamePlaceholder: "새 컬렉션 이름",
|
||||
cloneCollectionSuccess: '컬렉션 "{name}"을(를) 복제했습니다(문서 {documents}개, 인덱스 {indexes}개)',
|
||||
confirmDropCollectionTitle: "컬렉션 삭제",
|
||||
confirmDropCollectionMessage: '컬렉션 "{name}"을(를) 삭제하시겠습니까? 컬렉션과 모든 문서가 영구적으로 삭제됩니다.',
|
||||
dropCollectionSuccess: '컬렉션 "{name}" 삭제됨',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2608,6 +2608,10 @@ export default withEnglishFallback({
|
||||
attachSqliteDatabaseDesktopOnly: "附加 SQLite 数据库文件仅支持桌面端",
|
||||
dropDatabaseSuccess: "数据库「{name}」已删除",
|
||||
dropCollection: "删除集合",
|
||||
cloneCollection: "克隆集合",
|
||||
cloneCollectionTitle: "克隆为新集合",
|
||||
cloneCollectionNamePlaceholder: "新集合名",
|
||||
cloneCollectionSuccess: "已克隆集合「{name}」,共 {documents} 个文档和 {indexes} 个索引",
|
||||
confirmDropCollectionTitle: "删除集合",
|
||||
confirmDropCollectionMessage: "确定要删除集合「{name}」吗?这将永久删除该集合及其所有文档。",
|
||||
dropCollectionSuccess: "集合「{name}」已删除",
|
||||
|
||||
@@ -2537,6 +2537,10 @@ export default withEnglishFallback({
|
||||
attachSqliteDatabaseDesktopOnly: "附加 SQLite 資料庫檔案僅支援桌面端",
|
||||
dropDatabaseSuccess: "資料庫「{name}」已刪除",
|
||||
dropCollection: "刪除集合",
|
||||
cloneCollection: "複製集合",
|
||||
cloneCollectionTitle: "複製為新集合",
|
||||
cloneCollectionNamePlaceholder: "新集合名稱",
|
||||
cloneCollectionSuccess: "已複製集合「{name}」,共 {documents} 個文件和 {indexes} 個索引",
|
||||
confirmDropCollectionTitle: "刪除集合",
|
||||
confirmDropCollectionMessage: "確定要刪除集合「{name}」嗎?這將永久刪除該集合及其所有文件。",
|
||||
dropCollectionSuccess: "集合「{name}」已刪除",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user