feat(mongodb): rename collections from sidebar

This commit is contained in:
onenewcode
2026-07-21 17:08:27 +08:00
committed by GitHub
parent 809500fd52
commit 9a83b46f00
26 changed files with 1105 additions and 93 deletions
@@ -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(
</DialogContent>
</Dialog>
<Dialog v-model:open="showRenameMongoCollectionDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<Input v-model="renameMongoCollectionName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" :disabled="renameMongoCollectionLoading" @keydown.enter.prevent="confirmRenameMongoCollection" />
<pre v-if="renameMongoCollectionPreview" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{{ renameMongoCollectionPreview }}</pre>
<p v-if="renameMongoCollectionError" class="text-sm text-destructive">{{ renameMongoCollectionError }}</p>
</div>
<DialogFooter>
<Button variant="outline" :disabled="renameMongoCollectionLoading" @click="showRenameMongoCollectionDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="renameMongoCollectionLoading || !renameMongoCollectionName || renameMongoCollectionName === node.label" @click="confirmRenameMongoCollection">
<Loader2 v-if="renameMongoCollectionLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("contextMenu.renameObject") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showStructurePreviewDialog">
<DialogContent class="sm:max-w-[760px]">
<DialogHeader>
@@ -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) {
@@ -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,
@@ -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<string, unknown>) => (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> = {}): 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();
});
});
@@ -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<typeof useConnectionStore>;
}
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<TreeNode, "tableName">, indexName: string): string {
return `db.getCollection(${JSON.stringify(node.tableName || "")}).dropIndex(${JSON.stringify(indexName)})`;
function mongoIndexDropPreview(node: Pick<TreeNode, "database" | "tableName">, 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<TreeNode, "label">): string {
return `db.getCollection(${JSON.stringify(node.label)}).dropIndexes()`;
function mongoDropAllIndexesPreviewForNode(node: Pick<TreeNode, "database" | "label">): 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<TreeNode, "connectionId" | "database" | "schema" | "tableName" | "label">): 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,
+1
View File
@@ -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");
+4
View File
@@ -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<void> {
await post("/api/mongo/rename-collection", { connectionId, database, collection, newName });
}
export async function elasticsearchListIndices(connectionId: string): Promise<string[]> {
const collections = await documentListCollections(connectionId, "default");
return collections.map((c) => c.name);
+4
View File
@@ -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<void> {
return invoke("mongo_rename_collection", { connectionId, database, collection, newName });
}
export async function elasticsearchListIndices(connectionId: string): Promise<string[]> {
const collections = await documentListCollections(connectionId, "default");
return collections.map((c) => c.name);
@@ -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);
});
});
@@ -7,8 +7,12 @@ export function shouldIncludeDefaultDatabaseNode(connection: Pick<ConnectionConf
return connection?.db_type === "mysql" && databases.some((database) => !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[] {
@@ -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<T> {
@@ -26,3 +26,31 @@ export async function executeWithProductionSqlGuard<T>(options: ProductionSqlExe
}
return options.execute();
}
export interface ProductionContextExecutionGuardOptions<T> {
connection?: ConnectionConfig;
database?: string | null;
/** Review text shown in the production confirmation dialog (SQL or shell preview). */
reviewText: string;
source?: string;
execute: () => Promise<T>;
}
/**
* Gate non-SQL mutations (Mongo shell commands, structured editors, etc.) using
* connection/database production scope rather than SQL risk classification.
*/
export async function executeWithProductionContextGuard<T>(options: ProductionContextExecutionGuardOptions<T>): Promise<T | undefined> {
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();
}
@@ -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()');
});
});
@@ -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> = {}): 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();
});
});
@@ -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<TreeNode, "meta">): 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()`;
}
@@ -0,0 +1,50 @@
import type { Ref } from "vue";
import type { ConnectionConfig } from "@/types/database";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
export type RunMongoSidebarMutationOptions<T> = {
connection?: ConnectionConfig;
database: string;
reviewText: string;
source: string;
loading: Ref<boolean>;
/** Runs after production confirmation and after loading is set (e.g. ensureConnected). */
beforeExecute?: () => Promise<void>;
execute: () => Promise<T>;
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<T>(options: RunMongoSidebarMutationOptions<T>): Promise<void> {
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;
}
}
+14 -13
View File
@@ -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`,
+9 -2
View File
@@ -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;
}
+123
View File
@@ -203,6 +203,54 @@ pub async fn list_databases(client: &Client) -> Result<Vec<String>, 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<Vec<MongoCollectionSpec>, 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<Vec<String>, 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::<Document>(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<Document, String> {
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<Vec<IndexInfo>, String> {
let col = client.database(database).collection::<Document>(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 =
+25 -10
View File
@@ -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<String>) -> Vec<String> {
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<mongo_driver::MongoCollectionSpec>,
) -> Vec<mongo_driver::MongoCollectionSpec> {
specs.sort_by(|left, right| cmp_names(&left.name, &right.name));
specs
}
pub(crate) fn mongo_gridfs_bucket_names(names: &[String]) -> Vec<String> {
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<String> = 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()),
+16
View File
@@ -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,
+1
View File
@@ -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))
+29
View File
@@ -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<Arc<WebState>>,
headers: HeaderMap,
Json(req): Json<MongoRenameCollectionRequest>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
headers: HeaderMap,
+28
View File
@@ -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");
});
+8 -1
View File
@@ -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/);
+12
View File
@@ -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<AppState>>,
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(
+1
View File
@@ -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,