feat(mongodb): rename collections from sidebar

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-21 17:08:27 +08:00
1 parent 809500fd52
commit 9a83b46f00
26 files changed
+1105 -93

No files matched your search

@@ -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;
}