feat(mongodb): improve sidebar index management
This commit is contained in:
1 parent
22055e081f
commit
8e7b74d0ee
56 files changed
+3558
-246
No files matched your search
Vendored
+435
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user