diff --git a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue index f68b5db189..27c0a110d6 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue @@ -88,6 +88,12 @@ const { editNacosNamespaceDesc, editNacosNamespaceLoading, confirmEditNacosNamespace, + showRenameMongoCollectionDialog, + renameMongoCollectionName, + renameMongoCollectionError, + renameMongoCollectionPreview, + renameMongoCollectionLoading, + confirmRenameMongoCollection, showCreateSchemaDialog, createSchemaName, confirmCreateSchema, @@ -143,6 +149,7 @@ watch( showEditDatabasePropertiesDialog, showCreateNacosNamespaceDialog, showEditNacosNamespaceDialog, + showRenameMongoCollectionDialog, showCreateSchemaDialog, showEditSchemaCommentDialog, ], @@ -222,6 +229,26 @@ watch( + + + + {{ t("contextMenu.renameObjectTitle") }} + + + + {{ renameMongoCollectionPreview }} + {{ renameMongoCollectionError }} + + + {{ t("dangerDialog.cancel") }} + + + {{ t("contextMenu.renameObject") }} + + + + + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index e6f484acac..eb95062481 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -369,6 +369,14 @@ const { const { canDropMongoDatabase, canDropMongoCollection, + canRenameMongoCollection, + prepareRenameMongoCollectionDialog, + confirmRenameMongoCollection, + showRenameMongoCollectionDialog, + renameMongoCollectionName, + renameMongoCollectionError, + renameMongoCollectionPreview, + renameMongoCollectionLoading, mongoIndexNameForNode, canDropMongoIndexNode, canDropMongoIndex, @@ -384,6 +392,7 @@ const { dropAllMongoIndexes, flushRedisDb, confirmFlushRedisDb, + confirmDropMongoDatabase, confirmDropMongoCollection, confirmDropMongoIndex, confirmDropAllMongoIndexes, @@ -840,6 +849,10 @@ function requestRenameSelectedNode(): boolean { connectionStore.startEditing(editTarget.connectionId); return true; } + if (canRenameMongoCollection.value) { + openRenameMongoCollectionDialog(); + return true; + } if (canRenameObject.value) { openRenameObjectDialog(); return true; @@ -851,6 +864,12 @@ function requestRenameSelectedNode(): boolean { return false; } +function openRenameMongoCollectionDialog() { + claimTreeItemDialogOwnership(); + routeTreeItemDialogController(); + prepareRenameMongoCollectionDialog(); +} + function requestEditSelectedConnection(): boolean { const editTarget = selectedConnectionEditTarget(activeNode.value, selectedTreeNodesInVisibleOrder()); if (!editTarget) return false; @@ -2638,26 +2657,26 @@ function dropDatabase() { async function confirmDropDatabase() { const node = sidebarDangerTarget.value ?? activeNode.value; - if (!node.connectionId || dropDatabaseLoading.value) return; + if (node.type === "mongo-db") { + await confirmDropMongoDatabase(); + return; + } + + const connectionId = node.connectionId; + if (!connectionId || dropDatabaseLoading.value) return; dropDatabaseLoading.value = true; try { - await connectionStore.ensureConnected(node.connectionId); - if (node.type === "mongo-db" && node.database) { - await api.mongoDropDatabase(node.connectionId, node.database); - toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000); - await connectionStore.loadMongoDatabases(node.connectionId); - showDropDatabaseConfirm.value = false; - return; - } + await connectionStore.ensureConnected(connectionId); const sql = dropDatabasePreviewSql.value || (await buildDropDatabaseSql({ databaseType: databaseTypeForNode(node), name: node.label, })); - await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" }); + const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" }); + if (!executed) return; toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000); - await connectionStore.loadDatabases(node.connectionId, { force: true }); + await connectionStore.loadDatabases(connectionId, { force: true }); showDropDatabaseConfirm.value = false; } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); @@ -3316,6 +3335,12 @@ function databaseSpecificDialogCapabilities() { editNacosNamespaceDesc, editNacosNamespaceLoading, confirmEditNacosNamespace, + showRenameMongoCollectionDialog, + renameMongoCollectionName, + renameMongoCollectionError, + renameMongoCollectionPreview, + renameMongoCollectionLoading, + confirmRenameMongoCollection, showCreateSchemaDialog, createSchemaName, confirmCreateSchema, @@ -3792,6 +3817,14 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties }); items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + if (canRenameMongoCollection.value) { + items.push({ + label: t("contextMenu.renameObject"), + action: openRenameMongoCollectionDialog, + icon: Pencil, + shortcut: shortcutRename, + }); + } if (canDropAllMongoIndexes.value || canDropMongoCollection.value) { items.push({ label: "", separator: true }); if (canDropAllMongoIndexes.value) { diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index 70eb5c96ac..6f28c3a1f5 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -87,6 +87,11 @@ export const showDropDatabaseConfirm = ref(false); export const dropDatabaseLoading = ref(false); export const showDropMongoCollectionConfirm = ref(false); export const dropMongoCollectionLoading = ref(false); +export const showRenameMongoCollectionDialog = ref(false); +export const renameMongoCollectionName = ref(""); +export const renameMongoCollectionError = ref(""); +export const renameMongoCollectionPreview = ref(""); +export const renameMongoCollectionLoading = ref(false); export const showDropMongoIndexConfirm = ref(false); export const dropMongoIndexLoading = ref(false); export const showDropAllMongoIndexesConfirm = ref(false); @@ -130,6 +135,7 @@ const openFlags = [ showEditNacosNamespaceDialog, showDropDatabaseConfirm, showDropMongoCollectionConfirm, + showRenameMongoCollectionDialog, showDropMongoIndexConfirm, showDropAllMongoIndexesConfirm, showFlushRedisDbConfirm, diff --git a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.rename.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.rename.spec.ts new file mode 100644 index 0000000000..5832703341 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.rename.spec.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { shallowRef } from "vue"; +import type { TreeNode } from "@/types/database"; +import { renameMongoCollectionError, renameMongoCollectionLoading, renameMongoCollectionName, showRenameMongoCollectionDialog, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState"; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + ensureConnected: vi.fn().mockResolvedValue(undefined), + loadMongoCollections: vi.fn().mockResolvedValue(undefined), + mongoRenameCollection: vi.fn(), + getConfig: vi.fn(() => ({ + id: "conn-1", + name: "Mongo", + db_type: "mongodb" as const, + host: "localhost", + port: 27017, + username: "op", + password: "", + driver_profile: undefined as string | undefined, + })), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key), + }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + getConfig: mocks.getConfig, + ensureConnected: mocks.ensureConnected, + loadMongoCollections: mocks.loadMongoCollections, + treeNodes: [], + }), +})); + +vi.mock("@/lib/backend/api", () => ({ + mongoRenameCollection: (...args: unknown[]) => mocks.mongoRenameCollection(...args), + mongoDropCollection: vi.fn(), + mongoDropDatabase: vi.fn(), + mongoDropIndexes: vi.fn(), + nacosCreateNamespace: vi.fn(), + nacosUpdateNamespace: vi.fn(), + redisFlushDb: vi.fn(), +})); + +vi.mock("@/lib/sidebar/sidebarActionTarget", () => ({ + findSidebarActionTarget: () => null, +})); + +import { useSidebarDatabaseSpecificMutationRuntime } from "@/composables/useSidebarDatabaseSpecificMutationRuntime"; + +function collectionNode(overrides: Partial = {}): TreeNode { + return { + id: "conn-1:app:users", + label: "users", + type: "mongo-collection", + connectionId: "conn-1", + database: "app", + meta: { collectionKind: "collection" }, + isExpanded: false, + ...overrides, + }; +} + +describe("confirmRenameMongoCollection existing target failure", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + mocks.getConfig.mockReturnValue({ + id: "conn-1", + name: "Mongo", + db_type: "mongodb", + host: "localhost", + port: 27017, + username: "op", + password: "", + driver_profile: undefined, + }); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.loadMongoCollections.mockResolvedValue(undefined); + mocks.mongoRenameCollection.mockReset(); + + const node = collectionNode(); + sidebarFormTarget.value = node; + renameMongoCollectionName.value = "accounts"; + renameMongoCollectionError.value = ""; + renameMongoCollectionLoading.value = false; + showRenameMongoCollectionDialog.value = true; + }); + + it("surfaces mock API reject for existing target without closing the dialog or toasting success", async () => { + mocks.mongoRenameCollection.mockRejectedValue(new Error("Namespace app.accounts already exists. target namespace exists")); + + const activeNode = shallowRef(collectionNode()); + const { confirmRenameMongoCollection } = useSidebarDatabaseSpecificMutationRuntime({ + activeNode, + connectionStore: { + getConfig: mocks.getConfig, + ensureConnected: mocks.ensureConnected, + loadMongoCollections: mocks.loadMongoCollections, + treeNodes: [], + } as any, + }); + + await confirmRenameMongoCollection(); + + expect(mocks.ensureConnected).toHaveBeenCalledWith("conn-1"); + expect(mocks.mongoRenameCollection).toHaveBeenCalledWith("conn-1", "app", "users", "accounts"); + expect(mocks.loadMongoCollections).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(showRenameMongoCollectionDialog.value).toBe(true); + expect(renameMongoCollectionError.value).toContain("target namespace exists"); + expect(renameMongoCollectionLoading.value).toBe(false); + }); + + it("does not call rename API when production confirmation is cancelled", async () => { + mocks.getConfig.mockReturnValue({ + id: "conn-1", + name: "Mongo Prod", + db_type: "mongodb", + host: "localhost", + port: 27017, + username: "op", + password: "", + is_production: true, + }); + + const activeNode = shallowRef(collectionNode()); + const { confirmRenameMongoCollection } = useSidebarDatabaseSpecificMutationRuntime({ + activeNode, + connectionStore: { + getConfig: mocks.getConfig, + ensureConnected: mocks.ensureConnected, + loadMongoCollections: mocks.loadMongoCollections, + treeNodes: [], + } as any, + }); + + const pending = confirmRenameMongoCollection(); + await Promise.resolve(); + + const { useProductionSafetyStore } = await import("@/stores/productionSafetyStore"); + useProductionSafetyStore().cancel(); + await pending; + + expect(mocks.mongoRenameCollection).not.toHaveBeenCalled(); + expect(showRenameMongoCollectionDialog.value).toBe(true); + expect(renameMongoCollectionError.value).toBe(""); + expect(mocks.toast).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts index 88ef5e6b0b..7549e5be26 100644 --- a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts @@ -1,4 +1,4 @@ -import { computed, type ShallowRef } from "vue"; +import { computed, watch, type ShallowRef } from "vue"; import { useI18n } from "vue-i18n"; import { useToast } from "@/composables/useToast"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -6,6 +6,8 @@ import type { TreeNode } from "@/types/database"; import * as api from "@/lib/backend/api"; import { translateBackendError } from "@/i18n/backend-errors"; import { findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget"; +import { isRenamableMongoCollection, mongoCollectionKindFromNode, mongoDropAllIndexesPreview, mongoDropCollectionPreview, mongoDropDatabasePreview, mongoDropIndexPreview, mongoRenameCollectionPreview } from "@/lib/sidebar/mongoCollectionMutation"; +import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation"; import { sidebarDangerTarget, sidebarFormTarget, @@ -24,7 +26,14 @@ import { dropMongoIndexLoading, showDropAllMongoIndexesConfirm, dropAllMongoIndexesLoading, + showDropDatabaseConfirm, + dropDatabaseLoading, showFlushRedisDbConfirm, + showRenameMongoCollectionDialog, + renameMongoCollectionName, + renameMongoCollectionError, + renameMongoCollectionPreview, + renameMongoCollectionLoading, } from "@/components/sidebar/sidebarTreeDialogState"; interface SidebarDatabaseSpecificMutationRuntimeOptions { @@ -32,6 +41,13 @@ interface SidebarDatabaseSpecificMutationRuntimeOptions { connectionStore: ReturnType; } +function errorMessage(error: unknown): string { + if (error && typeof error === "object" && "message" in error) { + return String((error as { message?: unknown }).message || error); + } + return String(error); +} + export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDatabaseSpecificMutationRuntimeOptions) { const { t } = useI18n(); const { toast } = useToast(); @@ -42,11 +58,77 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba return activeNode.value.type === "mongo-db" && !!activeNode.value.database && config?.driver_profile !== "mongodb-legacy"; }); - const canDropMongoCollection = computed(() => { - const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; - return activeNode.value.type === "mongo-collection" && !!activeNode.value.database && config?.driver_profile !== "mongodb-legacy"; + function canMutateMongoCollectionNode(node: TreeNode): boolean { + if (node.type !== "mongo-collection" || !node.connectionId || !node.database) return false; + const config = connectionStore.getConfig(node.connectionId); + return config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy"; + } + + function canRenameMongoCollectionNode(node: TreeNode): boolean { + return canMutateMongoCollectionNode(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node)); + } + + const canDropMongoCollection = computed(() => canMutateMongoCollectionNode(activeNode.value)); + const canRenameMongoCollection = computed(() => canRenameMongoCollectionNode(activeNode.value)); + + function toastMutationError(error: unknown) { + toast(t("contextMenu.tableOperationFailed", { message: errorMessage(error) }), 5000); + } + + function prepareRenameMongoCollectionDialog() { + renameMongoCollectionName.value = activeNode.value.label; + renameMongoCollectionError.value = ""; + renameMongoCollectionPreview.value = ""; + renameMongoCollectionLoading.value = false; + showRenameMongoCollectionDialog.value = true; + } + + function refreshRenameMongoCollectionPreview() { + const node = sidebarFormTarget.value ?? activeNode.value; + // Preserve identifier whitespace exactly as entered; only reject empty names. + const newName = renameMongoCollectionName.value; + if (!showRenameMongoCollectionDialog.value || !canRenameMongoCollectionNode(node) || !node.database || !newName || newName === node.label) { + renameMongoCollectionPreview.value = ""; + return; + } + renameMongoCollectionPreview.value = mongoRenameCollectionPreview(node.database, node.label, newName); + } + + watch([showRenameMongoCollectionDialog, renameMongoCollectionName, () => activeNode.value.label, () => activeNode.value.database], () => { + refreshRenameMongoCollectionPreview(); }); + async function confirmRenameMongoCollection() { + const node = sidebarFormTarget.value ?? activeNode.value; + const connectionId = node.connectionId; + const database = node.database; + const newName = renameMongoCollectionName.value; + if (!canRenameMongoCollectionNode(node) || !connectionId || !database || !newName || newName === node.label) { + return; + } + const oldName = node.label; + renameMongoCollectionError.value = ""; + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + reviewText: mongoRenameCollectionPreview(database, oldName, newName), + source: t("production.sourceSidebar"), + loading: renameMongoCollectionLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: async () => { + await api.mongoRenameCollection(connectionId, database, oldName, newName); + await connectionStore.loadMongoCollections(connectionId, database); + }, + onSuccess: () => { + toast(t("contextMenu.renameObjectSuccess", { oldName, newName }), 3000); + showRenameMongoCollectionDialog.value = false; + }, + onError: (error) => { + renameMongoCollectionError.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+\(.+\)$/, ""); @@ -60,17 +142,14 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba const canDropMongoIndex = computed(() => canDropMongoIndexNode(activeNode.value)); - function mongoIndexDropPreview(node: Pick, indexName: string): string { - return `db.getCollection(${JSON.stringify(node.tableName || "")}).dropIndex(${JSON.stringify(indexName)})`; + function mongoIndexDropPreview(node: Pick, indexName: string): string { + return mongoDropIndexPreview(node.database || "", node.tableName || "", indexName); } - const canDropAllMongoIndexes = computed(() => { - const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; - return activeNode.value.type === "mongo-collection" && !!activeNode.value.database && config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy"; - }); + const canDropAllMongoIndexes = computed(() => canMutateMongoCollectionNode(activeNode.value)); - function mongoDropAllIndexesPreview(node: Pick): string { - return `db.getCollection(${JSON.stringify(node.label)}).dropIndexes()`; + function mongoDropAllIndexesPreviewForNode(node: Pick): string { + return mongoDropAllIndexesPreview(node.database || "", node.label); } function openCreateNacosNamespaceDialog() { @@ -168,21 +247,53 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba } } + async function confirmDropMongoDatabase() { + const node = sidebarDangerTarget.value ?? activeNode.value; + const connectionId = node.connectionId; + const database = node.database; + if (node.type !== "mongo-db" || !connectionId || !database) return; + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + reviewText: mongoDropDatabasePreview(database), + source: t("production.sourceSidebar"), + loading: dropDatabaseLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: async () => { + await api.mongoDropDatabase(connectionId, database); + await connectionStore.loadMongoDatabases(connectionId); + }, + onSuccess: () => { + toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000); + showDropDatabaseConfirm.value = false; + }, + onError: toastMutationError, + }); + } + async function confirmDropMongoCollection() { const node = sidebarDangerTarget.value ?? activeNode.value; - if (node.type !== "mongo-collection" || !node.connectionId || !node.database || dropMongoCollectionLoading.value) return; - dropMongoCollectionLoading.value = true; - try { - await connectionStore.ensureConnected(node.connectionId); - await api.mongoDropCollection(node.connectionId, node.database, node.label); - toast(t("contextMenu.dropCollectionSuccess", { name: node.label }), 3000); - await connectionStore.loadMongoCollections(node.connectionId, node.database); - showDropMongoCollectionConfirm.value = false; - } catch (error: any) { - toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000); - } finally { - dropMongoCollectionLoading.value = false; - } + const connectionId = node.connectionId; + const database = node.database; + if (!canMutateMongoCollectionNode(node) || !connectionId || !database) return; + const collectionName = node.label; + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + reviewText: mongoDropCollectionPreview(database, collectionName), + source: t("production.sourceSidebar"), + loading: dropMongoCollectionLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: async () => { + await api.mongoDropCollection(connectionId, database, collectionName); + await connectionStore.loadMongoCollections(connectionId, database); + }, + onSuccess: () => { + toast(t("contextMenu.dropCollectionSuccess", { name: collectionName }), 3000); + showDropMongoCollectionConfirm.value = false; + }, + onError: toastMutationError, + }); } function mongoIndexesGroupNodeId(node: Pick): string | null { @@ -199,48 +310,73 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba async function confirmDropMongoIndex() { const node = sidebarDangerTarget.value ?? activeNode.value; - if (!canDropMongoIndexNode(node) || !node.connectionId || !node.database || !node.tableName || dropMongoIndexLoading.value) return; - dropMongoIndexLoading.value = true; - try { - await connectionStore.ensureConnected(node.connectionId); - const indexName = mongoIndexNameForNode(node); - await api.mongoDropIndexes(node.connectionId, node.database, node.tableName, JSON.stringify(indexName), true); - toast(t("contextMenu.dropTableChildObjectSuccess", { name: indexName }), 3000); - showDropMongoIndexConfirm.value = false; - await refreshMongoIndexTree(node); - } catch (error: any) { - toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000); - } finally { - dropMongoIndexLoading.value = false; - } + const connectionId = node.connectionId; + const database = node.database; + const tableName = node.tableName; + if (!canDropMongoIndexNode(node) || !connectionId || !database || !tableName) return; + const indexName = mongoIndexNameForNode(node); + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + reviewText: mongoDropIndexPreview(database, tableName, indexName), + source: t("production.sourceSidebar"), + loading: dropMongoIndexLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: async () => { + await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(indexName), true); + await refreshMongoIndexTree(node); + }, + onSuccess: () => { + toast(t("contextMenu.dropTableChildObjectSuccess", { name: indexName }), 3000); + showDropMongoIndexConfirm.value = false; + }, + onError: toastMutationError, + }); } async function confirmDropAllMongoIndexes() { const node = sidebarDangerTarget.value ?? activeNode.value; - if (node.type !== "mongo-collection" || !node.connectionId || !node.database || dropAllMongoIndexesLoading.value) return; - dropAllMongoIndexesLoading.value = true; - try { - await connectionStore.ensureConnected(node.connectionId); - const result = await api.mongoDropIndexes(node.connectionId, node.database, node.label, undefined, false); - toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: node.label }), 3000); - showDropAllMongoIndexesConfirm.value = false; - await refreshMongoIndexTree(node); - } catch (error: any) { - toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000); - } finally { - dropAllMongoIndexesLoading.value = false; - } + const connectionId = node.connectionId; + const database = node.database; + if (!canMutateMongoCollectionNode(node) || !connectionId || !database) return; + const collectionName = node.label; + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + reviewText: mongoDropAllIndexesPreview(database, collectionName), + source: t("production.sourceSidebar"), + loading: dropAllMongoIndexesLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: async () => { + const result = await api.mongoDropIndexes(connectionId, database, collectionName, undefined, false); + await refreshMongoIndexTree(node); + return result; + }, + onSuccess: (result) => { + toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: collectionName }), 3000); + showDropAllMongoIndexesConfirm.value = false; + }, + onError: toastMutationError, + }); } return { canDropMongoDatabase, canDropMongoCollection, + canRenameMongoCollection, + prepareRenameMongoCollectionDialog, + confirmRenameMongoCollection, + showRenameMongoCollectionDialog, + renameMongoCollectionName, + renameMongoCollectionError, + renameMongoCollectionPreview, + renameMongoCollectionLoading, mongoIndexNameForNode, canDropMongoIndexNode, canDropMongoIndex, mongoIndexDropPreview, canDropAllMongoIndexes, - mongoDropAllIndexesPreview, + mongoDropAllIndexesPreview: mongoDropAllIndexesPreviewForNode, openCreateNacosNamespaceDialog, confirmCreateNacosNamespace, openEditNacosNamespaceDialog, @@ -250,6 +386,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba dropAllMongoIndexes, flushRedisDb, confirmFlushRedisDb, + confirmDropMongoDatabase, confirmDropMongoCollection, confirmDropMongoIndex, confirmDropAllMongoIndexes, diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 9b7ddfbe6b..774bce5bcd 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -458,6 +458,7 @@ export const vectorGetCollectionDetail = forward("vectorGetCollectionDetail"); export const mongoCreateDatabase = forward("mongoCreateDatabase"); export const mongoDropDatabase = forward("mongoDropDatabase"); export const mongoDropCollection = forward("mongoDropCollection"); +export const mongoRenameCollection = forward("mongoRenameCollection"); export const documentFindDocuments = forward("documentFindDocuments"); export const mongoFindDocuments = forward("mongoFindDocuments"); export const mongoParseShellCommand = forward("mongoParseShellCommand"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 0266be20d5..ffa7c61574 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2080,6 +2080,10 @@ export async function mongoDropCollection(connectionId: string, database: string await post("/api/mongo/drop-collection", { connectionId, database, collection }); } +export async function mongoRenameCollection(connectionId: string, database: string, collection: string, newName: string): Promise { + await post("/api/mongo/rename-collection", { connectionId, database, collection, newName }); +} + export async function elasticsearchListIndices(connectionId: string): Promise { const collections = await documentListCollections(connectionId, "default"); return collections.map((c) => c.name); diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index c66d3ec79f..af4ee6920e 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1872,6 +1872,10 @@ export async function mongoDropCollection(connectionId: string, database: string return invoke("mongo_drop_collection", { connectionId, database, collection }); } +export async function mongoRenameCollection(connectionId: string, database: string, collection: string, newName: string): Promise { + return invoke("mongo_rename_collection", { connectionId, database, collection, newName }); +} + export async function elasticsearchListIndices(connectionId: string): Promise { const collections = await documentListCollections(connectionId, "default"); return collections.map((c) => c.name); diff --git a/apps/desktop/src/lib/database/__tests__/productionExecutionGuard.spec.ts b/apps/desktop/src/lib/database/__tests__/productionExecutionGuard.spec.ts index ba95ffde91..a81293b1f1 100644 --- a/apps/desktop/src/lib/database/__tests__/productionExecutionGuard.spec.ts +++ b/apps/desktop/src/lib/database/__tests__/productionExecutionGuard.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPinia, setActivePinia } from "pinia"; -import { executeWithProductionSqlGuard } from "../productionExecutionGuard"; +import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "../productionExecutionGuard"; import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import type { ConnectionConfig } from "@/types/database"; @@ -117,3 +117,67 @@ describe("production SQL execution guard", () => { expect(execute).toHaveBeenCalledTimes(1); }); }); + +describe("production context execution guard (non-SQL)", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("waits for confirmation before executing production non-SQL mutations", async () => { + const execute = vi.fn().mockResolvedValue("renamed"); + const reviewText = 'db.getSiblingDB("app").getCollection("users").renameCollection("accounts")'; + const pendingExecution = executeWithProductionContextGuard({ + connection: connection({ db_type: "mongodb", is_production: true }), + database: "app", + reviewText, + source: "Object tree", + execute, + }); + + await Promise.resolve(); + const store = useProductionSafetyStore(); + expect(store.pending).toMatchObject({ + sql: reviewText, + database: "app", + source: "Object tree", + }); + expect(execute).not.toHaveBeenCalled(); + + store.confirm(); + await expect(pendingExecution).resolves.toBe("renamed"); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("cancels production non-SQL mutations without executing the callback", async () => { + const execute = vi.fn().mockResolvedValue("renamed"); + const pendingExecution = executeWithProductionContextGuard({ + connection: connection({ db_type: "mongodb", production_databases: ["app"] }), + database: "app", + reviewText: 'db.getCollection("users").renameCollection("accounts")', + source: "Object tree", + execute, + }); + + await Promise.resolve(); + useProductionSafetyStore().cancel(); + + await expect(pendingExecution).resolves.toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + }); + + it("executes non-production non-SQL mutations immediately", async () => { + const execute = vi.fn().mockResolvedValue("renamed"); + await expect( + executeWithProductionContextGuard({ + connection: connection({ db_type: "mongodb" }), + database: "scratch", + reviewText: 'db.getCollection("users").renameCollection("accounts")', + source: "Object tree", + execute, + }), + ).resolves.toBe("renamed"); + + expect(useProductionSafetyStore().pending).toBeUndefined(); + expect(execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/lib/database/databaseTree.ts b/apps/desktop/src/lib/database/databaseTree.ts index 6e69b8c521..4ac8dfa024 100644 --- a/apps/desktop/src/lib/database/databaseTree.ts +++ b/apps/desktop/src/lib/database/databaseTree.ts @@ -7,8 +7,12 @@ export function shouldIncludeDefaultDatabaseNode(connection: Pick !database.name.trim()); } +export function compareSidebarNames(left: string, right: string): number { + return sidebarNameCollator.compare(left, right); +} + export function sortSidebarNames(names: readonly string[]): string[] { - return [...names].sort((left, right) => sidebarNameCollator.compare(left, right)); + return [...names].sort(compareSidebarNames); } export function sortSidebarDatabases(databases: readonly DatabaseInfo[]): DatabaseInfo[] { diff --git a/apps/desktop/src/lib/database/productionExecutionGuard.ts b/apps/desktop/src/lib/database/productionExecutionGuard.ts index 06fa905eca..0e8bc9aaeb 100644 --- a/apps/desktop/src/lib/database/productionExecutionGuard.ts +++ b/apps/desktop/src/lib/database/productionExecutionGuard.ts @@ -1,5 +1,5 @@ import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; -import { assessProductionSql } from "@/lib/database/productionSafety"; +import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety"; import type { ConnectionConfig } from "@/types/database"; export interface ProductionSqlExecutionGuardOptions { @@ -26,3 +26,31 @@ export async function executeWithProductionSqlGuard(options: ProductionSqlExe } return options.execute(); } + +export interface ProductionContextExecutionGuardOptions { + connection?: ConnectionConfig; + database?: string | null; + /** Review text shown in the production confirmation dialog (SQL or shell preview). */ + reviewText: string; + source?: string; + execute: () => Promise; +} + +/** + * Gate non-SQL mutations (Mongo shell commands, structured editors, etc.) using + * connection/database production scope rather than SQL risk classification. + */ +export async function executeWithProductionContextGuard(options: ProductionContextExecutionGuardOptions): Promise { + const productionContext = productionContextForDatabase(options.connection, options.database); + if (productionContext.active) { + const confirmed = await useProductionSafetyStore().requestConfirmation({ + sql: options.reviewText, + connectionName: options.connection?.name, + database: options.database ?? undefined, + productionDatabases: productionContext.databases, + source: options.source, + }); + if (!confirmed) return undefined; + } + return options.execute(); +} diff --git a/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts new file mode 100644 index 0000000000..a194f67092 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { isRenamableMongoCollection, mongoCollectionKindFromNode, mongoDropAllIndexesPreview, mongoDropCollectionPreview, mongoDropIndexPreview, mongoRenameCollectionPreview, toMongoCollectionKind } from "../mongoCollectionMutation"; + +describe("isRenamableMongoCollection", () => { + it("allows ordinary collections and defaults", () => { + expect(isRenamableMongoCollection("users")).toBe(true); + expect(isRenamableMongoCollection("users", "collection")).toBe(true); + }); + + it("rejects views, time-series collections, and system namespaces", () => { + expect(isRenamableMongoCollection("users_view", "view")).toBe(false); + expect(isRenamableMongoCollection("metrics", "timeseries")).toBe(false); + expect(isRenamableMongoCollection("system.views", "collection")).toBe(false); + }); +}); + +describe("mongoCollectionKindFromNode", () => { + it("reads collectionKind from node meta without using SQL tableType", () => { + expect(mongoCollectionKindFromNode({ meta: { collectionKind: "view" } })).toBe("view"); + expect(mongoCollectionKindFromNode({ meta: { collectionKind: "timeseries" } })).toBe("timeseries"); + expect(mongoCollectionKindFromNode({ meta: { collectionKind: "collection" } })).toBe("collection"); + expect(mongoCollectionKindFromNode({})).toBe("collection"); + }); +}); + +describe("toMongoCollectionKind", () => { + it("normalizes wire kinds", () => { + expect(toMongoCollectionKind("view")).toBe("view"); + expect(toMongoCollectionKind("timeseries")).toBe("timeseries"); + expect(toMongoCollectionKind("bucket")).toBe("collection"); + expect(toMongoCollectionKind(undefined)).toBe("collection"); + }); +}); + +describe("mongo shell previews", () => { + it("preserves identifier whitespace in rename preview", () => { + expect(mongoRenameCollectionPreview("app", " users ", " renamed ")).toBe('db.getSiblingDB("app").getCollection(" users ").renameCollection(" renamed ")'); + }); + + 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")'); + expect(mongoDropAllIndexesPreview("app", "users")).toBe('db.getSiblingDB("app").getCollection("users").dropIndexes()'); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/__tests__/runMongoSidebarMutation.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/runMongoSidebarMutation.spec.ts new file mode 100644 index 0000000000..5fb794a09b --- /dev/null +++ b/apps/desktop/src/lib/sidebar/__tests__/runMongoSidebarMutation.spec.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { ref } from "vue"; +import { runMongoSidebarMutation } from "../runMongoSidebarMutation"; +import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; +import type { ConnectionConfig } from "@/types/database"; + +function connection(overrides: Partial = {}): ConnectionConfig { + return { + id: "conn-1", + name: "Mongo", + db_type: "mongodb", + host: "localhost", + port: 27017, + username: "op", + password: "", + ...overrides, + }; +} + +describe("runMongoSidebarMutation", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("calls onSuccess for void execute results (not treated as cancel)", async () => { + const loading = ref(false); + const execute = vi.fn().mockResolvedValue(undefined); + const onSuccess = vi.fn(); + const beforeExecute = vi.fn().mockResolvedValue(undefined); + + await runMongoSidebarMutation({ + connection: connection(), + database: "app", + reviewText: 'db.getCollection("users").renameCollection("accounts")', + source: "Object tree", + loading, + beforeExecute, + execute, + onSuccess, + }); + + expect(beforeExecute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith(undefined); + expect(loading.value).toBe(false); + }); + + it("passes through non-void execute results to onSuccess", async () => { + const loading = ref(false); + const result = { dropped_names: ["a", "b"] }; + const onSuccess = vi.fn(); + + await runMongoSidebarMutation({ + connection: connection(), + database: "app", + reviewText: 'db.getCollection("users").dropIndexes()', + source: "Object tree", + loading, + execute: async () => result, + onSuccess, + }); + + expect(onSuccess).toHaveBeenCalledWith(result); + }); + + it("does not execute or call onSuccess when production confirmation is cancelled", async () => { + const loading = ref(false); + const execute = vi.fn().mockResolvedValue(undefined); + const onSuccess = vi.fn(); + const beforeExecute = vi.fn(); + + const pending = runMongoSidebarMutation({ + connection: connection({ is_production: true }), + database: "app", + reviewText: 'db.getCollection("users").drop()', + source: "Object tree", + loading, + beforeExecute, + execute, + onSuccess, + }); + + await Promise.resolve(); + expect(useProductionSafetyStore().pending).toBeTruthy(); + expect(loading.value).toBe(false); + expect(execute).not.toHaveBeenCalled(); + + useProductionSafetyStore().cancel(); + await pending; + + expect(beforeExecute).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(loading.value).toBe(false); + }); + + it("executes after production confirmation", async () => { + const loading = ref(false); + const execute = vi.fn().mockResolvedValue(undefined); + const onSuccess = vi.fn(); + + const pending = runMongoSidebarMutation({ + connection: connection({ is_production: true }), + database: "app", + reviewText: 'db.getCollection("users").drop()', + source: "Object tree", + loading, + execute, + onSuccess, + }); + + await Promise.resolve(); + useProductionSafetyStore().confirm(); + await pending; + + expect(execute).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("routes existing-target reject to onError without onSuccess", async () => { + // Mongo rename of an existing target fails at the server (no dropTarget). + const loading = ref(false); + const onError = vi.fn(); + const onSuccess = vi.fn(); + const existingTargetError = new Error("Command failed: Namespace app.accounts already exists. target namespace exists"); + + await runMongoSidebarMutation({ + connection: connection(), + database: "app", + reviewText: 'db.getSiblingDB("app").getCollection("users").renameCollection("accounts")', + source: "Object tree", + loading, + execute: async () => { + throw existingTargetError; + }, + onSuccess, + onError, + }); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toBe(existingTargetError); + expect(loading.value).toBe(false); + }); + + it("skips work when already loading", async () => { + const loading = ref(true); + const execute = vi.fn(); + const onSuccess = vi.fn(); + + await runMongoSidebarMutation({ + connection: connection(), + database: "app", + reviewText: "drop", + source: "Object tree", + loading, + execute, + onSuccess, + }); + + expect(execute).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts new file mode 100644 index 0000000000..6699f3b734 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts @@ -0,0 +1,45 @@ +import type { MongoCollectionKind, TreeNode } from "@/types/database"; + +/** + * MongoDB only supports renameCollection for ordinary, non-system collections. + * Views, time-series collections, and reserved system namespaces must not expose a rename action. + * @see https://www.mongodb.com/docs/manual/reference/command/renameCollection/ + */ +export function isRenamableMongoCollection(name: string, kind: MongoCollectionKind = "collection"): boolean { + return kind === "collection" && !name.startsWith("system."); +} + +export function mongoCollectionKindFromNode(node: Pick): MongoCollectionKind { + const meta = node.meta; + if (meta && "collectionKind" in meta && meta.collectionKind) { + return meta.collectionKind; + } + return "collection"; +} + +export function toMongoCollectionKind(kind?: string | null): MongoCollectionKind { + const normalized = (kind || "collection").toLowerCase(); + if (normalized === "view") return "view"; + if (normalized === "timeseries") return "timeseries"; + return "collection"; +} + +export function mongoRenameCollectionPreview(database: string, oldName: string, newName: string): string { + return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(oldName)}).renameCollection(${JSON.stringify(newName)})`; +} + +export function mongoDropCollectionPreview(database: string, collection: string): string { + return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).drop()`; +} + +export function mongoDropDatabasePreview(database: string): string { + return `db.getSiblingDB(${JSON.stringify(database)}).dropDatabase()`; +} + +export function mongoDropIndexPreview(database: string, collection: string, indexName: string): string { + return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).dropIndex(${JSON.stringify(indexName)})`; +} + +export function mongoDropAllIndexesPreview(database: string, collection: string): string { + return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).dropIndexes()`; +} diff --git a/apps/desktop/src/lib/sidebar/runMongoSidebarMutation.ts b/apps/desktop/src/lib/sidebar/runMongoSidebarMutation.ts new file mode 100644 index 0000000000..05d1ff1d46 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/runMongoSidebarMutation.ts @@ -0,0 +1,50 @@ +import type { Ref } from "vue"; +import type { ConnectionConfig } from "@/types/database"; +import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard"; + +export type RunMongoSidebarMutationOptions = { + connection?: ConnectionConfig; + database: string; + reviewText: string; + source: string; + loading: Ref; + /** Runs after production confirmation and after loading is set (e.g. ensureConnected). */ + beforeExecute?: () => Promise; + execute: () => Promise; + onSuccess: (result: T) => void; + onError?: (error: unknown) => void; +}; + +/** + * Shared production-gated mutation shell for Mongo sidebar writes. + * Production confirmation runs before loading so cancel never shows a busy state. + * + * Success is boxed as `{ result }` so void executes are not confused with cancel + * (`executeWithProductionContextGuard` returns `undefined` only when cancelled). + */ +export async function runMongoSidebarMutation(options: RunMongoSidebarMutationOptions): Promise { + if (options.loading.value) return; + try { + const executed = await executeWithProductionContextGuard({ + connection: options.connection, + database: options.database, + reviewText: options.reviewText, + source: options.source, + execute: async () => { + options.loading.value = true; + if (options.beforeExecute) { + await options.beforeExecute(); + } + const result = await options.execute(); + return { result }; + }, + }); + // Cancel only: guard returns undefined when the user declines production confirmation. + if (executed === undefined) return; + options.onSuccess(executed.result); + } catch (error: unknown) { + options.onError?.(error); + } finally { + options.loading.value = false; + } +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index e8c2645e51..53162703af 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -45,7 +45,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; +import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree"; import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse"; import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget"; @@ -81,6 +81,7 @@ import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilit import { useSettingsStore } from "@/stores/settingsStore"; import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers"; import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion"; +import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation"; import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex"; import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; @@ -2788,18 +2789,18 @@ export const useConnectionStore = defineStore("connection", () => { const collections = await api.mongoListCollections(connectionId, database); const bucketNames = new Set(collections.filter((c) => c.kind === "bucket" && c.bucketName).map((c) => c.bucketName as string)); const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`])); - const collectionNames = collections - .filter((c) => c.kind !== "bucket") - .map((c) => c.name) - .filter((name) => !hiddenCollectionNames.has(name)); - const collectionChildren = sortSidebarNames(collectionNames).map((col) => ({ - id: `${nodeId}:${col}`, - label: col, - type: "mongo-collection" as const, - connectionId, - database, - isExpanded: false, - })); + const collectionEntries = collections.filter((c) => c.kind !== "bucket").filter((c) => !hiddenCollectionNames.has(c.name)); + const collectionChildren = [...collectionEntries] + .sort((left, right) => compareSidebarNames(left.name, right.name)) + .map((col) => ({ + id: `${nodeId}:${col.name}`, + label: col.name, + type: "mongo-collection" as const, + connectionId, + database, + meta: { collectionKind: toMongoCollectionKind(col.kind) }, + isExpanded: false, + })); const children = [ { id: `${nodeId}:__gridfs`, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 7ff217b4df..32a6cf051e 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -718,7 +718,7 @@ export interface TreeNode { tableSearchParentId?: string; savedSqlId?: string; savedSqlFolderId?: string; - meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ExtensionInfo | VectorCollectionMeta; + meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta; loadMore?: { parentId: string; offset: number; @@ -955,10 +955,17 @@ export interface VectorCollectionMeta { collectionId?: string; } +/** Mongo collection node metadata (not SQL tableType). */ +export type MongoCollectionKind = "collection" | "view" | "timeseries"; + +export interface MongoCollectionMeta { + collectionKind: MongoCollectionKind; +} + export interface CollectionInfo { name: string; id: string; dimension?: number; - kind?: "collection" | "bucket"; + kind?: MongoCollectionKind | "bucket"; bucketName?: string; } diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index f81de6f2f4..01e1fdf291 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -203,6 +203,54 @@ pub async fn list_databases(client: &Client) -> Result, String> { client.list_database_names().await.map_err(|e| e.to_string()) } +/// MongoDB collection kind from `listCollections` (not GridFS buckets). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MongoCollectionKind { + Collection, + View, + Timeseries, +} + +impl MongoCollectionKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Collection => "collection", + Self::View => "view", + Self::Timeseries => "timeseries", + } + } + + pub fn from_driver_type(collection_type: &mongodb::results::CollectionType) -> Self { + match collection_type { + mongodb::results::CollectionType::View => Self::View, + mongodb::results::CollectionType::Timeseries => Self::Timeseries, + // Collection and any future non_exhaustive variants default to a renamable collection. + _ => Self::Collection, + } + } +} + +/// Name + kind for a MongoDB collection/view/timeseries returned by full `listCollections`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MongoCollectionSpec { + pub name: String, + pub kind: MongoCollectionKind, +} + +/// Full `listCollections` with type metadata (for sidebar rename gating, etc.). +pub async fn list_collection_specs(client: &Client, database: &str) -> Result, String> { + let mut cursor = client.database(database).list_collections().await.map_err(|e| e.to_string())?; + let mut specs = Vec::new(); + while let Some(spec) = cursor.try_next().await.map_err(|e| e.to_string())? { + specs.push(MongoCollectionSpec { + name: spec.name, + kind: MongoCollectionKind::from_driver_type(&spec.collection_type), + }); + } + Ok(specs) +} + +/// Name-only listing (schema, GridFS helpers, and other callers that do not need type). pub async fn list_collections(client: &Client, database: &str) -> Result, String> { client.database(database).list_collection_names().await.map_err(|e| e.to_string()) } @@ -505,6 +553,39 @@ pub async fn drop_collection(client: &Client, database: &str, collection: &str) client.database(database).collection::(collection).drop().await.map_err(|e| e.to_string()) } +/// Build the admin `renameCollection` command document for a same-database rename. +/// +/// Collection (and database) names are identifiers and must be passed exactly as provided — +/// do not trim or otherwise normalize them before building the namespace. +pub fn rename_collection_command_document(database: &str, old_name: &str, new_name: &str) -> Result { + if database.is_empty() { + return Err("Database name is required".to_string()); + } + if old_name.is_empty() { + return Err("Collection name is required".to_string()); + } + if new_name.is_empty() { + return Err("New collection name is required".to_string()); + } + if old_name == new_name { + return Err("New collection name must differ from the current name".to_string()); + } + // MongoDB reserves system.* namespaces; reject them before exposing a rename that the server cannot perform. + if old_name.starts_with("system.") || new_name.starts_with("system.") { + return Err("System collections cannot be renamed".to_string()); + } + Ok(doc! { + "renameCollection": format!("{database}.{old_name}"), + "to": format!("{database}.{new_name}"), + }) +} + +pub async fn rename_collection(client: &Client, database: &str, old_name: &str, new_name: &str) -> Result<(), String> { + let command = rename_collection_command_document(database, old_name, new_name)?; + client.database("admin").run_command(command).await.map_err(|e| e.to_string())?; + Ok(()) +} + pub async fn list_indexes(client: &Client, database: &str, collection: &str) -> Result, String> { let col = client.database(database).collection::(collection); let mut cursor = col.list_indexes().await.map_err(|e| e.to_string())?; @@ -1953,6 +2034,48 @@ mod tests { assert!(error.contains("unknown field `upsert`")); } + #[test] + fn rename_collection_command_uses_fully_qualified_names() { + let command = rename_collection_command_document("app", "users", "accounts").unwrap(); + assert_eq!(command.get_str("renameCollection").unwrap(), "app.users"); + assert_eq!(command.get_str("to").unwrap(), "app.accounts"); + } + + #[test] + fn rename_collection_command_rejects_invalid_names() { + assert!(rename_collection_command_document("", "users", "accounts").unwrap_err().contains("Database")); + assert!(rename_collection_command_document("app", "", "accounts").unwrap_err().contains("Collection")); + assert!(rename_collection_command_document("app", "users", "").unwrap_err().contains("New collection")); + assert!(rename_collection_command_document("app", "users", "users").unwrap_err().contains("differ")); + assert!(rename_collection_command_document("app", "system.views", "views_backup") + .unwrap_err() + .contains("System collections")); + assert!(rename_collection_command_document("app", "users", "system.users") + .unwrap_err() + .contains("System collections")); + } + + #[test] + fn rename_collection_command_preserves_identifier_whitespace() { + let command = rename_collection_command_document("app", " users ", " renamed ").unwrap(); + assert_eq!(command.get_str("renameCollection").unwrap(), "app. users "); + assert_eq!(command.get_str("to").unwrap(), "app. renamed "); + } + + #[test] + fn rename_collection_command_does_not_drop_existing_target() { + // Existing target names must fail at the server instead of being overwritten. + let command = rename_collection_command_document("app", "users", "accounts").unwrap(); + assert!(!command.contains_key("dropTarget")); + } + + #[test] + fn mongo_collection_kind_as_str_is_stable_wire_value() { + assert_eq!(MongoCollectionKind::Collection.as_str(), "collection"); + assert_eq!(MongoCollectionKind::View.as_str(), "view"); + assert_eq!(MongoCollectionKind::Timeseries.as_str(), "timeseries"); + } + #[test] fn multi_seed_uri_removes_direct_connection_true_before_driver_parse() { let uri = diff --git a/crates/dbx-core/src/document_ops.rs b/crates/dbx-core/src/document_ops.rs index d9e6be0c1f..5d6dccc59e 100644 --- a/crates/dbx-core/src/document_ops.rs +++ b/crates/dbx-core/src/document_ops.rs @@ -27,12 +27,14 @@ pub struct MongoGridFsBucketInfo { pub total_bytes: i64, } +fn cmp_names(left: &str, right: &str) -> std::cmp::Ordering { + let left_lower = left.to_lowercase(); + let right_lower = right.to_lowercase(); + left_lower.cmp(&right_lower).then_with(|| left.cmp(right)) +} + fn sort_names(mut names: Vec) -> Vec { - names.sort_by(|left, right| { - let left_lower = left.to_lowercase(); - let right_lower = right.to_lowercase(); - left_lower.cmp(&right_lower).then_with(|| left.cmp(right)) - }); + names.sort_by(|left, right| cmp_names(left, right)); names } @@ -84,16 +86,23 @@ fn mongo_list_databases_unauthorized(error: &str) -> bool { lower.contains("not authorized") && lower.contains("listdatabases") } -fn mongo_collection_info(name: String) -> CollectionInfo { +fn mongo_collection_info(name: String, kind: mongo_driver::MongoCollectionKind) -> CollectionInfo { CollectionInfo { name: name.clone(), id: name, dimension: None, - kind: Some("collection".to_string()), + kind: Some(kind.as_str().to_string()), bucket_name: None, } } +fn sort_mongo_collection_specs( + mut specs: Vec, +) -> Vec { + specs.sort_by(|left, right| cmp_names(&left.name, &right.name)); + specs +} + pub(crate) fn mongo_gridfs_bucket_names(names: &[String]) -> Vec { use std::collections::BTreeSet; @@ -204,9 +213,10 @@ pub async fn list_collections_core( let connections = state.connections.read().await; match connections.get(connection_id).ok_or("Not found")? { PoolKind::MongoDb(client) => { - let names = sort_names(mongo_driver::list_collections(client, database).await?); + let specs = sort_mongo_collection_specs(mongo_driver::list_collection_specs(client, database).await?); + let names: Vec = specs.iter().map(|spec| spec.name.clone()).collect(); let mut infos = mongo_bucket_infos(&names); - infos.extend(names.into_iter().map(mongo_collection_info)); + infos.extend(specs.into_iter().map(|spec| mongo_collection_info(spec.name, spec.kind))); Ok(infos) } PoolKind::Elasticsearch(client) => { @@ -221,7 +231,12 @@ pub async fn list_collections_core( let mut client = client.lock().await; let names = sort_names(client.mongo_list_collections(database).await?); let mut infos = mongo_bucket_infos(&names); - infos.extend(names.into_iter().map(mongo_collection_info)); + // Legacy agent returns names only; treat every entry as a plain collection. + infos.extend( + names + .into_iter() + .map(|name| mongo_collection_info(name, mongo_driver::MongoCollectionKind::Collection)), + ); Ok(infos) } _ => Err("Not a MongoDB/Elasticsearch/vector connection".to_string()), diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index f4df577da8..cdd3a934fd 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -62,6 +62,22 @@ pub async fn mongo_drop_collection_core( } } +pub async fn mongo_rename_collection_core( + state: &AppState, + connection_id: &str, + database: &str, + collection: &str, + new_name: &str, +) -> Result<(), String> { + ensure_document_pool(state, connection_id).await?; + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::MongoDb(client) => mongo_driver::rename_collection(client, database, collection, new_name).await, + PoolKind::Agent(_) => Err("MongoDB legacy agent does not support rename collection".to_string()), + _ => Err("Not a MongoDB connection".to_string()), + } +} + pub async fn mongo_server_version_core( state: &AppState, connection_id: &str, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 2828d4f21b..1b21287b93 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -450,6 +450,7 @@ async fn main() { .route("/mongo/create-database", post(routes::mongo::create_database)) .route("/mongo/drop-database", post(routes::mongo::drop_database)) .route("/mongo/drop-collection", post(routes::mongo::drop_collection)) + .route("/mongo/rename-collection", post(routes::mongo::rename_collection)) .route("/document-store/list-databases", post(routes::document_store::list_databases)) .route("/document-store/list-collections", post(routes::document_store::list_collections)) .route("/document-store/find-documents", post(routes::document_store::find_documents)) diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 1f9939ee0a..02e2b0925b 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -77,6 +77,15 @@ pub struct MongoCollectionNameRequest { pub collection: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MongoRenameCollectionRequest { + pub connection_id: String, + pub database: String, + pub collection: String, + pub new_name: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct MongoFindRequest { @@ -350,6 +359,26 @@ pub async fn drop_collection( Ok(Json(serde_json::json!({ "ok": true }))) } +pub async fn rename_collection( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Rename collection") + .await?; + ensure_writable(&state.app, &req.connection_id, "Rename collection").await?; + dbx_core::mongo_ops::mongo_rename_collection_core( + &state.app, + &req.connection_id, + &req.database, + &req.collection, + &req.new_name, + ) + .await + .map_err(AppError)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + pub async fn find_documents( State(state): State>, headers: HeaderMap, diff --git a/packages/app-tests/productionGuardEntrypoints.test.ts b/packages/app-tests/productionGuardEntrypoints.test.ts index 1b7d86de2e..a75ce36410 100644 --- a/packages/app-tests/productionGuardEntrypoints.test.ts +++ b/packages/app-tests/productionGuardEntrypoints.test.ts @@ -105,3 +105,31 @@ test("object browser batch empty reviews the frozen SQL plan before executing", assert.match(body, /runBatchTableEmpty/, "batch empty should still use the batch executor after confirmation"); assert.ok(body.indexOf("executeObjectBrowserSqlWithProductionGuard") < body.indexOf("runBatchTableEmpty"), "production guard must be entered before the batch executor"); }); + +test("mongo sidebar mutations share the production-gated runMongoSidebarMutation shell", () => { + const shellSource = readSource("apps/desktop/src/lib/sidebar/runMongoSidebarMutation.ts"); + assert.match(shellSource, /executeWithProductionContextGuard/, "shared mongo mutation shell must request production confirmation"); + assert.match(shellSource, /return \{\s*result\s*\}/, "void execute success must be boxed so it is not treated as cancel"); + assert.match(shellSource, /if \(executed === undefined\) return/, "only unboxed undefined from the guard means cancel"); + assert.match(shellSource, /onSuccess\(executed\.result\)/, "onSuccess must receive the unboxed execute result"); + + const source = readSource("apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts"); + for (const name of [ + "confirmRenameMongoCollection", + "confirmDropMongoCollection", + "confirmDropMongoIndex", + "confirmDropAllMongoIndexes", + "confirmDropMongoDatabase", + ] as const) { + const body = functionBody(source, name); + assert.match(body, /runMongoSidebarMutation/, `${name} must use the shared mongo mutation shell`); + assert.ok(body.includes("production.sourceSidebar"), `${name} should label the confirmation source`); + } + + assert.ok(source.includes("api.mongoRenameCollection"), "mongo rename should still call the rename API"); + assert.ok(source.includes("api.mongoDropDatabase"), "mongo drop database should still call the drop API"); + + const dropDatabaseBody = functionBody(readSource("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue"), "confirmDropDatabase"); + assert.match(dropDatabaseBody, /confirmDropMongoDatabase/, "host drop-database confirm should delegate mongo to the mutation runtime"); + assert.ok(!dropDatabaseBody.includes("api.mongoDropDatabase"), "host drop-database confirm should not call mongo APIs directly"); +}); diff --git a/packages/app-tests/sidebarMutationRuntime.test.ts b/packages/app-tests/sidebarMutationRuntime.test.ts index 486564d544..c05008c7e7 100644 --- a/packages/app-tests/sidebarMutationRuntime.test.ts +++ b/packages/app-tests/sidebarMutationRuntime.test.ts @@ -34,7 +34,7 @@ test("mutation families retain accepted targets, failures, and refresh work", () assert.match(body, /tableOperationFailed/); } - for (const name of ["confirmCreateNacosNamespace", "confirmEditNacosNamespace", "confirmDropDatabase", "confirmDropMongoCollection"]) { + for (const name of ["confirmCreateNacosNamespace", "confirmEditNacosNamespace", "confirmDropDatabase"]) { const body = functionBody(name); assert.match(body, /try \{/); assert.match(body, /catch \([A-Za-z]+: any\)/); @@ -42,6 +42,13 @@ test("mutation families retain accepted targets, failures, and refresh work", () assert.match(body, /Loading\.value = false/); } + // Mongo collection mutations share a production-gated shell for failures / loading. + const dropMongoCollection = functionBody("confirmDropMongoCollection"); + assert.match(dropMongoCollection, /runMongoSidebarMutation/); + assert.match(dropMongoCollection, /onError:\s*toastMutationError/); + assert.match(dropMongoCollection, /api\.mongoDropCollection/); + + const redis = functionBody("confirmFlushRedisDb"); assert.match(redis, /updateRedisDbKeyStats/); assert.match(redis, /dbx-redis-db-flushed/); diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index e76b181be4..61fb3a5919 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -86,6 +86,18 @@ pub async fn mongo_drop_collection( dbx_core::mongo_ops::mongo_drop_collection_core(&state, &connection_id, &database, &collection).await } +#[tauri::command] +pub async fn mongo_rename_collection( + state: State<'_, Arc>, + connection_id: String, + database: String, + collection: String, + new_name: String, +) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "Rename collection").await?; + dbx_core::mongo_ops::mongo_rename_collection_core(&state, &connection_id, &database, &collection, &new_name).await +} + #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn mongo_find_documents( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 16d864f118..0660c2f180 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1291,6 +1291,7 @@ pub fn run() { commands::mongo_cmd::mongo_create_database, commands::mongo_cmd::mongo_drop_database, commands::mongo_cmd::mongo_drop_collection, + commands::mongo_cmd::mongo_rename_collection, commands::document_cmd::document_list_databases, commands::document_cmd::document_list_collections, commands::document_cmd::document_find_documents,
{{ renameMongoCollectionPreview }}
{{ renameMongoCollectionError }}