feat(mongodb): improve sidebar index management

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-02 22:45:33 +08:00
1 parent 22055e081f
commit 8e7b74d0ee
56 files changed
+3558 -246

No files matched your search

@@ -0,0 +1,435 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { shallowRef } from "vue";
import type { TreeNode } from "@/types/database";
import { mongoCreateIndexError, mongoCreateIndexForm, resetMongoCreateIndexForm, sidebarDangerTarget, sidebarFormTarget, showCreateMongoIndexDialog, showDropAllMongoIndexesConfirm, showDropMongoCollectionConfirm } from "@/components/sidebar/sidebarTreeDialogState";
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
ensureConnected: vi.fn().mockResolvedValue(undefined),
loadIndexes: vi.fn().mockResolvedValue(undefined),
listMongoCompletionFields: vi.fn().mockResolvedValue([{ name: "email", type: "string" }]),
loadMongoCollections: vi.fn().mockResolvedValue(undefined),
loadMongoDatabases: vi.fn().mockResolvedValue(undefined),
removeTreeNode: vi.fn(),
mongoCreateIndex: vi.fn().mockResolvedValue({ name: "email_1" }),
mongoDropCollection: vi.fn().mockResolvedValue(undefined),
mongoDropDatabase: vi.fn().mockResolvedValue(undefined),
mongoDropIndexes: vi.fn().mockResolvedValue({ dropped_names: ["email_1"], affected_rows: 1 }),
getConfig: vi.fn(),
}));
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: () => ({}),
}));
vi.mock("@/lib/backend/api", () => ({
mongoCreateIndex: (...args: unknown[]) => mocks.mongoCreateIndex(...args),
mongoDropCollection: (...args: unknown[]) => mocks.mongoDropCollection(...args),
mongoDropDatabase: (...args: unknown[]) => mocks.mongoDropDatabase(...args),
mongoDropIndexes: (...args: unknown[]) => mocks.mongoDropIndexes(...args),
mongoRenameCollection: vi.fn(),
nacosCreateNamespace: vi.fn(),
nacosUpdateNamespace: vi.fn(),
redisFlushDb: vi.fn(),
}));
vi.mock("@/lib/sidebar/sidebarActionTarget", () => ({
findSidebarActionTarget: () => null,
}));
import { useSidebarDatabaseSpecificMutationRuntime } from "@/composables/useSidebarDatabaseSpecificMutationRuntime";
function mongoConfig(driverProfile?: string, production = false) {
return {
id: "conn-1",
name: "Mongo",
db_type: "mongodb" as const,
driver_profile: driverProfile,
host: "localhost",
port: 27017,
username: "op",
password: "",
is_production: production,
};
}
function mongoDatabaseNode(): TreeNode {
return {
id: "conn-1:app",
label: "app",
type: "mongo-db",
connectionId: "conn-1",
database: "app",
isExpanded: false,
};
}
function mongoCollectionNode(kind: "collection" | "view" | "timeseries" = "collection"): TreeNode {
return {
id: "conn-1:app:users",
label: "users",
type: "mongo-collection",
connectionId: "conn-1",
database: "app",
meta: { collectionKind: kind },
isExpanded: false,
};
}
function mongoIndexesGroupNode(kind: "collection" | "view" | "timeseries" = "collection"): TreeNode {
return {
id: "conn-1:app:users:__indexes",
label: "tree.indexes",
type: "group-indexes",
connectionId: "conn-1",
database: "app",
tableName: "users",
meta: { collectionKind: kind },
isExpanded: false,
children: [],
};
}
function mongoIndexNode(name: string, kind: "collection" | "view" | "timeseries" = "collection", isPrimary = name === "_id_"): TreeNode {
return {
id: `conn-1:app:users:__indexes:${name}`,
label: `${name} (email)`,
type: "index",
connectionId: "conn-1",
database: "app",
tableName: "users",
meta: { name, columns: ["email"], is_primary: isPrimary, is_unique: false, collectionKind: kind },
isExpanded: false,
};
}
function runtime(activeNode: TreeNode) {
const indexesGroup = activeNode.type === "group-indexes" ? activeNode : mongoIndexesGroupNode();
return useSidebarDatabaseSpecificMutationRuntime({
activeNode: shallowRef(activeNode),
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
removeTreeNode: mocks.removeTreeNode,
treeNodes: [indexesGroup],
} as any,
});
}
describe("MongoDB sidebar mutation runtime", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mocks.getConfig.mockReturnValue(mongoConfig());
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.loadIndexes.mockResolvedValue(undefined);
mocks.listMongoCompletionFields.mockResolvedValue([{ name: "email", type: "string" }]);
mocks.loadMongoCollections.mockResolvedValue(undefined);
mocks.loadMongoDatabases.mockResolvedValue(undefined);
mocks.mongoCreateIndex.mockResolvedValue({ name: "email_1" });
mocks.mongoDropCollection.mockResolvedValue(undefined);
mocks.mongoDropDatabase.mockResolvedValue(undefined);
mocks.mongoDropIndexes.mockResolvedValue({ dropped_names: ["email_1"], affected_rows: 1 });
sidebarDangerTarget.value = null;
sidebarFormTarget.value = null;
showCreateMongoIndexDialog.value = false;
showDropAllMongoIndexesConfirm.value = false;
showDropMongoCollectionConfirm.value = false;
resetMongoCreateIndexForm();
});
it("allows Legacy connections to create and delete MongoDB tree objects while keeping rename native-only", () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const activeNode = shallowRef(mongoDatabaseNode());
const feature = useSidebarDatabaseSpecificMutationRuntime({
activeNode,
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
treeNodes: [],
} as any,
});
expect(feature.canDropMongoDatabase.value).toBe(true);
activeNode.value = mongoCollectionNode();
expect(feature.canDropMongoCollection.value).toBe(true);
expect(feature.canDropAllMongoIndexes.value).toBe(true);
expect(feature.canRenameMongoCollection.value).toBe(false);
expect(feature.canCreateMongoIndex.value).toBe(true);
activeNode.value = mongoIndexesGroupNode();
expect(feature.canCreateMongoIndex.value).toBe(true);
activeNode.value = mongoIndexNode("email_1");
expect(feature.canDropMongoIndex.value).toBe(true);
activeNode.value = mongoIndexNode("_id_");
expect(feature.canDropMongoIndex.value).toBe(false);
});
it("keeps collection deletion available for views without exposing unsupported index actions", () => {
const activeNode = shallowRef(mongoCollectionNode("view"));
const feature = useSidebarDatabaseSpecificMutationRuntime({
activeNode,
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
treeNodes: [],
} as any,
});
expect(feature.canDropMongoCollection.value).toBe(true);
expect(feature.canDropAllMongoIndexes.value).toBe(false);
activeNode.value = mongoIndexesGroupNode("view");
expect(feature.canCreateMongoIndex.value).toBe(false);
activeNode.value = mongoIndexNode("email_1", "view");
expect(feature.canDropMongoIndex.value).toBe(false);
});
it("creates an index from the shared sidebar dialog state", async () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const node = mongoIndexesGroupNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value = {
name: "email_created_at",
fields: [
{ id: 1, path: "email", type: "1" },
{ id: 2, path: "createdAt", type: "-1" },
],
unique: true,
sparse: false,
};
await feature.confirmCreateMongoIndex();
expect(showCreateMongoIndexDialog.value).toBe(false);
expect(mocks.ensureConnected).toHaveBeenCalledWith("conn-1");
expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1,"createdAt":-1}', '{"name":"email_created_at","unique":true}');
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
expect(mocks.toast).toHaveBeenCalledWith('contextMenu.createMongoIndexSuccess:{"name":"email_1","collection":"users"}', 3000);
});
it("starts every create-index dialog with safe defaults", () => {
const feature = runtime(mongoIndexesGroupNode());
mongoCreateIndexForm.value = {
name: "stale_index",
fields: [{ id: 7, path: "location", type: "2dsphere" }],
unique: true,
sparse: true,
};
mongoCreateIndexError.value = "previous failure";
feature.prepareCreateMongoIndexDialog();
expect(mongoCreateIndexForm.value).toEqual({
name: "",
fields: [{ id: 1, path: "", type: "1" }],
unique: false,
sparse: false,
});
expect(mongoCreateIndexError.value).toBe("");
expect(showCreateMongoIndexDialog.value).toBe(true);
});
it("allows a collection menu to create an index before its Indexes group is expanded", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
await feature.confirmCreateMongoIndex();
expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1}', undefined);
});
it("keeps the sidebar form target when the active node changes", async () => {
const originalTarget = mongoIndexesGroupNode();
const activeNode = shallowRef(originalTarget);
const feature = useSidebarDatabaseSpecificMutationRuntime({
activeNode,
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
treeNodes: [originalTarget],
} as any,
});
sidebarFormTarget.value = originalTarget;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
activeNode.value = {
...mongoIndexesGroupNode(),
id: "conn-1:app:orders:__indexes",
tableName: "orders",
};
await feature.confirmCreateMongoIndex();
expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1}', undefined);
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
});
it("drops every removable index through the shared mutation and refreshes metadata", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarDangerTarget.value = node;
showDropAllMongoIndexesConfirm.value = true;
mocks.mongoDropIndexes.mockResolvedValueOnce({ dropped_names: ["email_1", "created_at_-1"], affected_rows: 2 });
await feature.confirmDropAllMongoIndexes();
expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, false);
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
expect(showDropAllMongoIndexesConfirm.value).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith('contextMenu.dropAllIndexesSuccess:{"count":2,"name":"users"}', 3000);
});
it("refreshes index metadata after a failed delete request", async () => {
const node = mongoIndexNode("email_1");
const feature = runtime(node);
sidebarDangerTarget.value = node;
mocks.mongoDropIndexes.mockRejectedValueOnce(new Error("connection lost"));
await feature.confirmDropMongoIndex();
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.tableOperationFailed"), 5000);
});
it("reports partial index deletion after forcing a metadata refresh", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarDangerTarget.value = node;
mocks.mongoDropIndexes.mockResolvedValueOnce({
dropped_names: ["email_1"],
affected_rows: 1,
failures: [{ name: "missing_1", message: "index not found" }],
});
await feature.confirmDropAllMongoIndexes();
expect(mocks.loadIndexes).toHaveBeenCalledOnce();
expect(mocks.toast).toHaveBeenCalledWith('contextMenu.dropIndexesPartialFailure:{"success":1,"failed":1}', 5000);
});
it("executes Legacy delete operations and refreshes their MongoDB metadata", async () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const databaseNode = mongoDatabaseNode();
const databaseFeature = runtime(databaseNode);
sidebarDangerTarget.value = databaseNode;
await databaseFeature.confirmDropMongoDatabase();
expect(mocks.mongoDropDatabase).toHaveBeenCalledWith("conn-1", "app");
expect(mocks.loadMongoDatabases).toHaveBeenCalledWith("conn-1");
const collectionNode = mongoCollectionNode();
const collectionFeature = runtime(collectionNode);
sidebarDangerTarget.value = collectionNode;
await collectionFeature.confirmDropMongoCollection();
expect(mocks.mongoDropCollection).toHaveBeenCalledWith("conn-1", "app", "users");
expect(mocks.loadMongoDatabases).toHaveBeenCalledWith("conn-1");
expect(mocks.loadMongoCollections).toHaveBeenCalledWith("conn-1", "app");
const indexNode = mongoIndexNode("email_1");
const indexFeature = runtime(indexNode);
sidebarDangerTarget.value = indexNode;
await indexFeature.confirmDropMongoIndex();
expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", '"email_1"', true);
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
});
it("keeps a completed collection drop successful when metadata refresh fails", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarDangerTarget.value = node;
showDropMongoCollectionConfirm.value = true;
mocks.loadMongoDatabases.mockRejectedValueOnce(new Error("metadata unavailable"));
await feature.confirmDropMongoCollection();
expect(mocks.mongoDropCollection).toHaveBeenCalledWith("conn-1", "app", "users");
expect(showDropMongoCollectionConfirm.value).toBe(false);
expect(mocks.removeTreeNode).toHaveBeenCalledWith(node.id);
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.dropCollectionSuccess"), 3000);
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.objectDropRefreshFailed"), 5000);
expect(mocks.toast).not.toHaveBeenCalledWith(expect.stringContaining("contextMenu.tableOperationFailed"), 5000);
});
it("does not send a default _id_ index deletion request", async () => {
const node = mongoIndexNode("_id_");
const feature = runtime(node);
sidebarDangerTarget.value = node;
await feature.confirmDropMongoIndex();
expect(mocks.mongoDropIndexes).not.toHaveBeenCalled();
});
it("also hides indexes marked primary when metadata has an unexpected name", () => {
const feature = runtime(mongoIndexNode("unexpected_primary_name", "collection", true));
expect(feature.canDropMongoIndex.value).toBe(false);
});
it("reports an index-list refresh problem without misreporting a completed create as failed", async () => {
const node = mongoIndexesGroupNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
mocks.loadIndexes.mockRejectedValue(new Error("metadata unavailable"));
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
await feature.confirmCreateMongoIndex();
expect(mocks.mongoCreateIndex).toHaveBeenCalledOnce();
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.mongoIndexRefreshFailed"), 5000);
});
it("does not issue a create request when production confirmation is cancelled", async () => {
mocks.getConfig.mockReturnValue(mongoConfig(undefined, true));
const node = mongoIndexesGroupNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
const pending = feature.confirmCreateMongoIndex();
await Promise.resolve();
const { useProductionSafetyStore } = await import("@/stores/productionSafetyStore");
useProductionSafetyStore().cancel();
await pending;
expect(mocks.ensureConnected).not.toHaveBeenCalled();
expect(mocks.mongoCreateIndex).not.toHaveBeenCalled();
expect(showCreateMongoIndexDialog.value).toBe(true);
});
});
@@ -7,8 +7,23 @@ import * as api from "@/lib/backend/api";
import { translateBackendError } from "@/i18n/backend-errors";
import { notifyNacosNamespacesChanged } from "@/lib/nacos/nacosNamespaceCache";
import { findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { isRenamableMongoCollection, mongoCollectionKindFromNode, mongoDropAllIndexesPreview, mongoDropCollectionPreview, mongoDropDatabasePreview, mongoDropIndexPreview, mongoRenameCollectionPreview } from "@/lib/sidebar/mongoCollectionMutation";
import {
MONGO_INDEX_KEY_TYPES,
buildMongoCreateIndexRequest,
isProtectedMongoIndex,
isRenamableMongoCollection,
mongoCollectionKindFromNode,
mongoCreateIndexPreview,
mongoDropAllIndexesPreview,
mongoDropCollectionPreview,
mongoDropIndexFailureCount,
mongoDropDatabasePreview,
mongoDropIndexPreview,
mongoRenameCollectionPreview,
} from "@/lib/sidebar/mongoCollectionMutation";
import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import {
sidebarDangerTarget,
sidebarFormTarget,
@@ -38,6 +53,12 @@ import {
renameMongoCollectionError,
renameMongoCollectionPreview,
renameMongoCollectionLoading,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
resetMongoCreateIndexForm,
} from "@/components/sidebar/sidebarTreeDialogState";
interface SidebarDatabaseSpecificMutationRuntimeOptions {
@@ -57,19 +78,27 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const { toast } = useToast();
const { activeNode, connectionStore } = options;
const canDropMongoDatabase = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "mongo-db" && !!activeNode.value.database && config?.driver_profile !== "mongodb-legacy";
});
function usesAnyMongoDriver(node: Pick<TreeNode, "connectionId">): boolean {
return !!node.connectionId && supportsMongoAllDriverMutations(connectionStore.getConfig(node.connectionId));
}
function usesNativeMongoDriver(node: Pick<TreeNode, "connectionId">): boolean {
return !!node.connectionId && supportsNativeMongoDriverMutations(connectionStore.getConfig(node.connectionId));
}
function canMutateMongoIndexes(node: TreeNode): boolean {
return !!node.connectionId && supportsMongoIndexMutations(connectionStore.getConfig(node.connectionId), mongoCollectionKindFromNode(node));
}
const canDropMongoDatabase = computed(() => activeNode.value.type === "mongo-db" && !!activeNode.value.database && usesAnyMongoDriver(activeNode.value));
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";
return usesAnyMongoDriver(node);
}
function canRenameMongoCollectionNode(node: TreeNode): boolean {
return canMutateMongoCollectionNode(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node));
return canMutateMongoCollectionNode(node) && usesNativeMongoDriver(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node));
}
const canDropMongoCollection = computed(() => canMutateMongoCollectionNode(activeNode.value));
@@ -140,22 +169,104 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
function canDropMongoIndexNode(node: TreeNode): boolean {
if (node.type !== "index" || !node.connectionId || !node.database || !node.tableName) return false;
const config = connectionStore.getConfig(node.connectionId);
return config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy" && mongoIndexNameForNode(node) !== "_id_";
const isPrimary = !!(node.meta && "is_primary" in node.meta && node.meta.is_primary);
return canMutateMongoIndexes(node) && !isProtectedMongoIndex({ name: mongoIndexNameForNode(node), is_primary: isPrimary });
}
const canDropMongoIndex = computed(() => canDropMongoIndexNode(activeNode.value));
const canDropAllMongoIndexes = computed(() => activeNode.value.type === "mongo-collection" && canMutateMongoIndexes(activeNode.value));
function mongoIndexDropPreview(node: Pick<TreeNode, "database" | "tableName">, indexName: string): string {
return mongoDropIndexPreview(node.database || "", node.tableName || "", indexName);
}
const canDropAllMongoIndexes = computed(() => canMutateMongoCollectionNode(activeNode.value));
function mongoDropAllIndexesPreviewForNode(node: Pick<TreeNode, "database" | "label">): string {
return mongoDropAllIndexesPreview(node.database || "", node.label);
}
function canCreateMongoIndexNode(node: TreeNode): boolean {
const collectionName = mongoIndexCollectionName(node);
return !!collectionName && !!node.database && canMutateMongoIndexes(node);
}
const canCreateMongoIndex = computed(() => canCreateMongoIndexNode(activeNode.value));
const mongoCreateIndexCanSubmit = computed(() => mongoCreateIndexForm.value.fields.length > 0 && mongoCreateIndexForm.value.fields.every((field) => !!field.path.trim()));
const mongoCreateIndexCanAddField = computed(() => mongoCreateIndexForm.value.fields.every((field) => !!field.path.trim()));
watch(
mongoCreateIndexForm,
() => {
mongoCreateIndexError.value = "";
},
{ deep: true },
);
function mongoIndexCollectionName(node: TreeNode): string {
if (node.type === "mongo-collection") return node.label;
return node.type === "group-indexes" ? node.tableName || "" : "";
}
function prepareCreateMongoIndexDialog() {
const node = activeNode.value;
if (!canCreateMongoIndexNode(node) || !node.connectionId || !node.database) return;
resetMongoCreateIndexForm();
showCreateMongoIndexDialog.value = true;
void connectionStore
.listMongoCompletionFields(node.connectionId, node.database, mongoIndexCollectionName(node))
.then((fields) => {
const target = sidebarFormTarget.value ?? activeNode.value;
if (showCreateMongoIndexDialog.value && target.id === node.id) mongoCreateIndexFieldOptions.value = fields.map((field) => field.name);
})
.catch(() => {
// MongoDB is schemaless; users can still enter a field that was not sampled.
});
}
function addMongoCreateIndexField() {
if (!mongoCreateIndexCanAddField.value) return;
const nextId = Math.max(0, ...mongoCreateIndexForm.value.fields.map((field) => field.id)) + 1;
mongoCreateIndexForm.value.fields.push({ id: nextId, path: "", type: "1" });
}
function removeMongoCreateIndexField(id: number) {
if (mongoCreateIndexForm.value.fields.length === 1) return;
mongoCreateIndexForm.value.fields = mongoCreateIndexForm.value.fields.filter((field) => field.id !== id);
}
async function confirmCreateMongoIndex() {
const node = sidebarFormTarget.value ?? activeNode.value;
const connectionId = node.connectionId;
const database = node.database;
const collectionName = mongoIndexCollectionName(node);
if (!canCreateMongoIndexNode(node) || !connectionId || !database || !collectionName) return;
const request = buildMongoCreateIndexRequest(mongoCreateIndexForm.value);
if (!request.valid) {
mongoCreateIndexError.value = request.error === "field-duplicate" ? t("mongo.duplicateField", { field: request.field }) : t("contextMenu.createMongoIndexFieldRequired");
return;
}
mongoCreateIndexError.value = "";
await runMongoSidebarMutation({
connection: connectionStore.getConfig(connectionId),
database,
reviewText: mongoCreateIndexPreview(database, collectionName, request.keysJson, request.optionsJson),
source: t("production.sourceSidebar"),
loading: mongoCreateIndexLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: () => api.mongoCreateIndex(connectionId, database, collectionName, request.keysJson, request.optionsJson),
onSuccess: async (created) => {
showCreateMongoIndexDialog.value = false;
toast(t("contextMenu.createMongoIndexSuccess", { name: created.name, collection: collectionName }), 3000);
await refreshMongoIndexTreeAfterMutation({ ...node, tableName: collectionName });
},
onError: (error) => {
mongoCreateIndexError.value = translateBackendError(t, errorMessage(error));
},
});
}
function openCreateNacosNamespaceDialog() {
createNacosNamespaceId.value = "";
createNacosNamespaceName.value = "";
@@ -288,7 +399,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const node = sidebarDangerTarget.value ?? activeNode.value;
const connectionId = node.connectionId;
const database = node.database;
if (node.type !== "mongo-db" || !connectionId || !database) return;
if (node.type !== "mongo-db" || !connectionId || !database || !usesAnyMongoDriver(node)) return;
await runMongoSidebarMutation({
connection: connectionStore.getConfig(connectionId),
database,
@@ -296,13 +407,11 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
source: t("production.sourceSidebar"),
loading: dropDatabaseLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
await api.mongoDropDatabase(connectionId, database);
await connectionStore.loadMongoDatabases(connectionId);
},
onSuccess: () => {
execute: () => api.mongoDropDatabase(connectionId, database),
onSuccess: async () => {
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
showDropDatabaseConfirm.value = false;
await refreshMongoTreeAfterDrop(node, () => connectionStore.loadMongoDatabases(connectionId));
},
onError: toastMutationError,
});
@@ -321,28 +430,41 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
source: t("production.sourceSidebar"),
loading: dropMongoCollectionLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
await api.mongoDropCollection(connectionId, database, collectionName);
await connectionStore.loadMongoCollections(connectionId, database);
},
onSuccess: () => {
execute: () => api.mongoDropCollection(connectionId, database, collectionName),
onSuccess: async () => {
toast(t("contextMenu.dropCollectionSuccess", { name: collectionName }), 3000);
showDropMongoCollectionConfirm.value = false;
await refreshMongoTreeAfterDrop(node, async () => {
// The final collection can remove its database; if the database
// remains, refresh its preserved expanded children as well.
await connectionStore.loadMongoDatabases(connectionId);
await connectionStore.loadMongoCollections(connectionId, database);
});
},
onError: toastMutationError,
});
}
function mongoIndexesGroupNodeId(node: Pick<TreeNode, "connectionId" | "database" | "schema" | "tableName" | "label">): string | null {
if (!node.connectionId || !node.database) return null;
const tableName = node.tableName || node.label;
return node.schema ? `${node.connectionId}:${node.database}:${node.schema}:${tableName}:__indexes` : `${node.connectionId}:${node.database}:${tableName}:__indexes`;
async function refreshMongoTreeAfterDrop(node: TreeNode, refresh: () => Promise<void>) {
try {
await refresh();
} catch (error) {
connectionStore.removeTreeNode(node.id);
toast(t("contextMenu.objectDropRefreshFailed", { message: translateBackendError(t, errorMessage(error)) }), 5000);
}
}
async function refreshMongoIndexTree(node: Pick<TreeNode, "connectionId" | "database" | "schema" | "tableName" | "label">) {
const nodeId = mongoIndexesGroupNodeId(node);
if (!node.connectionId || !node.database || !nodeId) return;
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName || node.label, node.schema, nodeId);
async function refreshMongoIndexTreeAfterMutation(node: Pick<TreeNode, "connectionId" | "database" | "tableName" | "label">) {
if (!node.connectionId || !node.database) return;
try {
await refreshLoadedMongoIndexes(connectionStore, {
connectionId: node.connectionId,
database: node.database,
collection: node.tableName || node.label,
});
} catch (error) {
toast(t("contextMenu.mongoIndexRefreshFailed", { message: translateBackendError(t, errorMessage(error)) }), 5000);
}
}
async function confirmDropMongoIndex() {
@@ -360,11 +482,19 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
loading: dropMongoIndexLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(indexName), true);
await refreshMongoIndexTree(node);
try {
return await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(indexName), true);
} finally {
await refreshMongoIndexTreeAfterMutation(node);
}
},
onSuccess: () => {
toast(t("contextMenu.dropTableChildObjectSuccess", { name: indexName }), 3000);
onSuccess: (result) => {
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropTableChildObjectSuccess", { name: indexName }), 3000);
}
showDropMongoIndexConfirm.value = false;
},
onError: toastMutationError,
@@ -375,7 +505,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const node = sidebarDangerTarget.value ?? activeNode.value;
const connectionId = node.connectionId;
const database = node.database;
if (!canMutateMongoCollectionNode(node) || !connectionId || !database) return;
if (node.type !== "mongo-collection" || !canMutateMongoIndexes(node) || !connectionId || !database) return;
const collectionName = node.label;
await runMongoSidebarMutation({
connection: connectionStore.getConfig(connectionId),
@@ -385,12 +515,19 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
loading: dropAllMongoIndexesLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
const result = await api.mongoDropIndexes(connectionId, database, collectionName, undefined, false);
await refreshMongoIndexTree(node);
return result;
try {
return await api.mongoDropIndexes(connectionId, database, collectionName, undefined, false);
} finally {
await refreshMongoIndexTreeAfterMutation(node);
}
},
onSuccess: (result) => {
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: collectionName }), 3000);
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: collectionName }), 3000);
}
showDropAllMongoIndexesConfirm.value = false;
},
onError: toastMutationError,
@@ -411,9 +548,18 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
mongoIndexNameForNode,
canDropMongoIndexNode,
canDropMongoIndex,
mongoIndexDropPreview,
canDropAllMongoIndexes,
mongoIndexDropPreview,
mongoDropAllIndexesPreview: mongoDropAllIndexesPreviewForNode,
refreshMongoIndexTreeAfterMutation,
canCreateMongoIndex,
mongoIndexKeyTypes: MONGO_INDEX_KEY_TYPES,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
prepareCreateMongoIndexDialog,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
openCreateNacosNamespaceDialog,
confirmCreateNacosNamespace,
openEditNacosNamespaceDialog,