mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:59:03 +00:00
feat(mongodb): rename collections from sidebar
This commit is contained in:
@@ -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,
|
||||
|
||||
Vendored
+158
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -203,6 +203,54 @@ pub async fn list_databases(client: &Client) -> Result<Vec<String>, String> {
|
||||
client.list_database_names().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// MongoDB collection kind from `listCollections` (not GridFS buckets).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MongoCollectionKind {
|
||||
Collection,
|
||||
View,
|
||||
Timeseries,
|
||||
}
|
||||
|
||||
impl MongoCollectionKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Collection => "collection",
|
||||
Self::View => "view",
|
||||
Self::Timeseries => "timeseries",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_driver_type(collection_type: &mongodb::results::CollectionType) -> Self {
|
||||
match collection_type {
|
||||
mongodb::results::CollectionType::View => Self::View,
|
||||
mongodb::results::CollectionType::Timeseries => Self::Timeseries,
|
||||
// Collection and any future non_exhaustive variants default to a renamable collection.
|
||||
_ => Self::Collection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name + kind for a MongoDB collection/view/timeseries returned by full `listCollections`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MongoCollectionSpec {
|
||||
pub name: String,
|
||||
pub kind: MongoCollectionKind,
|
||||
}
|
||||
|
||||
/// Full `listCollections` with type metadata (for sidebar rename gating, etc.).
|
||||
pub async fn list_collection_specs(client: &Client, database: &str) -> Result<Vec<MongoCollectionSpec>, String> {
|
||||
let mut cursor = client.database(database).list_collections().await.map_err(|e| e.to_string())?;
|
||||
let mut specs = Vec::new();
|
||||
while let Some(spec) = cursor.try_next().await.map_err(|e| e.to_string())? {
|
||||
specs.push(MongoCollectionSpec {
|
||||
name: spec.name,
|
||||
kind: MongoCollectionKind::from_driver_type(&spec.collection_type),
|
||||
});
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
/// Name-only listing (schema, GridFS helpers, and other callers that do not need type).
|
||||
pub async fn list_collections(client: &Client, database: &str) -> Result<Vec<String>, String> {
|
||||
client.database(database).list_collection_names().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -505,6 +553,39 @@ pub async fn drop_collection(client: &Client, database: &str, collection: &str)
|
||||
client.database(database).collection::<Document>(collection).drop().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Build the admin `renameCollection` command document for a same-database rename.
|
||||
///
|
||||
/// Collection (and database) names are identifiers and must be passed exactly as provided —
|
||||
/// do not trim or otherwise normalize them before building the namespace.
|
||||
pub fn rename_collection_command_document(database: &str, old_name: &str, new_name: &str) -> Result<Document, String> {
|
||||
if database.is_empty() {
|
||||
return Err("Database name is required".to_string());
|
||||
}
|
||||
if old_name.is_empty() {
|
||||
return Err("Collection name is required".to_string());
|
||||
}
|
||||
if new_name.is_empty() {
|
||||
return Err("New collection name is required".to_string());
|
||||
}
|
||||
if old_name == new_name {
|
||||
return Err("New collection name must differ from the current name".to_string());
|
||||
}
|
||||
// MongoDB reserves system.* namespaces; reject them before exposing a rename that the server cannot perform.
|
||||
if old_name.starts_with("system.") || new_name.starts_with("system.") {
|
||||
return Err("System collections cannot be renamed".to_string());
|
||||
}
|
||||
Ok(doc! {
|
||||
"renameCollection": format!("{database}.{old_name}"),
|
||||
"to": format!("{database}.{new_name}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn rename_collection(client: &Client, database: &str, old_name: &str, new_name: &str) -> Result<(), String> {
|
||||
let command = rename_collection_command_document(database, old_name, new_name)?;
|
||||
client.database("admin").run_command(command).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_indexes(client: &Client, database: &str, collection: &str) -> Result<Vec<IndexInfo>, String> {
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
let mut cursor = col.list_indexes().await.map_err(|e| e.to_string())?;
|
||||
@@ -1953,6 +2034,48 @@ mod tests {
|
||||
assert!(error.contains("unknown field `upsert`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_collection_command_uses_fully_qualified_names() {
|
||||
let command = rename_collection_command_document("app", "users", "accounts").unwrap();
|
||||
assert_eq!(command.get_str("renameCollection").unwrap(), "app.users");
|
||||
assert_eq!(command.get_str("to").unwrap(), "app.accounts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_collection_command_rejects_invalid_names() {
|
||||
assert!(rename_collection_command_document("", "users", "accounts").unwrap_err().contains("Database"));
|
||||
assert!(rename_collection_command_document("app", "", "accounts").unwrap_err().contains("Collection"));
|
||||
assert!(rename_collection_command_document("app", "users", "").unwrap_err().contains("New collection"));
|
||||
assert!(rename_collection_command_document("app", "users", "users").unwrap_err().contains("differ"));
|
||||
assert!(rename_collection_command_document("app", "system.views", "views_backup")
|
||||
.unwrap_err()
|
||||
.contains("System collections"));
|
||||
assert!(rename_collection_command_document("app", "users", "system.users")
|
||||
.unwrap_err()
|
||||
.contains("System collections"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_collection_command_preserves_identifier_whitespace() {
|
||||
let command = rename_collection_command_document("app", " users ", " renamed ").unwrap();
|
||||
assert_eq!(command.get_str("renameCollection").unwrap(), "app. users ");
|
||||
assert_eq!(command.get_str("to").unwrap(), "app. renamed ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_collection_command_does_not_drop_existing_target() {
|
||||
// Existing target names must fail at the server instead of being overwritten.
|
||||
let command = rename_collection_command_document("app", "users", "accounts").unwrap();
|
||||
assert!(!command.contains_key("dropTarget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongo_collection_kind_as_str_is_stable_wire_value() {
|
||||
assert_eq!(MongoCollectionKind::Collection.as_str(), "collection");
|
||||
assert_eq!(MongoCollectionKind::View.as_str(), "view");
|
||||
assert_eq!(MongoCollectionKind::Timeseries.as_str(), "timeseries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_seed_uri_removes_direct_connection_true_before_driver_parse() {
|
||||
let uri =
|
||||
|
||||
@@ -27,12 +27,14 @@ pub struct MongoGridFsBucketInfo {
|
||||
pub total_bytes: i64,
|
||||
}
|
||||
|
||||
fn cmp_names(left: &str, right: &str) -> std::cmp::Ordering {
|
||||
let left_lower = left.to_lowercase();
|
||||
let right_lower = right.to_lowercase();
|
||||
left_lower.cmp(&right_lower).then_with(|| left.cmp(right))
|
||||
}
|
||||
|
||||
fn sort_names(mut names: Vec<String>) -> Vec<String> {
|
||||
names.sort_by(|left, right| {
|
||||
let left_lower = left.to_lowercase();
|
||||
let right_lower = right.to_lowercase();
|
||||
left_lower.cmp(&right_lower).then_with(|| left.cmp(right))
|
||||
});
|
||||
names.sort_by(|left, right| cmp_names(left, right));
|
||||
names
|
||||
}
|
||||
|
||||
@@ -84,16 +86,23 @@ fn mongo_list_databases_unauthorized(error: &str) -> bool {
|
||||
lower.contains("not authorized") && lower.contains("listdatabases")
|
||||
}
|
||||
|
||||
fn mongo_collection_info(name: String) -> CollectionInfo {
|
||||
fn mongo_collection_info(name: String, kind: mongo_driver::MongoCollectionKind) -> CollectionInfo {
|
||||
CollectionInfo {
|
||||
name: name.clone(),
|
||||
id: name,
|
||||
dimension: None,
|
||||
kind: Some("collection".to_string()),
|
||||
kind: Some(kind.as_str().to_string()),
|
||||
bucket_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_mongo_collection_specs(
|
||||
mut specs: Vec<mongo_driver::MongoCollectionSpec>,
|
||||
) -> Vec<mongo_driver::MongoCollectionSpec> {
|
||||
specs.sort_by(|left, right| cmp_names(&left.name, &right.name));
|
||||
specs
|
||||
}
|
||||
|
||||
pub(crate) fn mongo_gridfs_bucket_names(names: &[String]) -> Vec<String> {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -204,9 +213,10 @@ pub async fn list_collections_core(
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => {
|
||||
let names = sort_names(mongo_driver::list_collections(client, database).await?);
|
||||
let specs = sort_mongo_collection_specs(mongo_driver::list_collection_specs(client, database).await?);
|
||||
let names: Vec<String> = specs.iter().map(|spec| spec.name.clone()).collect();
|
||||
let mut infos = mongo_bucket_infos(&names);
|
||||
infos.extend(names.into_iter().map(mongo_collection_info));
|
||||
infos.extend(specs.into_iter().map(|spec| mongo_collection_info(spec.name, spec.kind)));
|
||||
Ok(infos)
|
||||
}
|
||||
PoolKind::Elasticsearch(client) => {
|
||||
@@ -221,7 +231,12 @@ pub async fn list_collections_core(
|
||||
let mut client = client.lock().await;
|
||||
let names = sort_names(client.mongo_list_collections(database).await?);
|
||||
let mut infos = mongo_bucket_infos(&names);
|
||||
infos.extend(names.into_iter().map(mongo_collection_info));
|
||||
// Legacy agent returns names only; treat every entry as a plain collection.
|
||||
infos.extend(
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| mongo_collection_info(name, mongo_driver::MongoCollectionKind::Collection)),
|
||||
);
|
||||
Ok(infos)
|
||||
}
|
||||
_ => Err("Not a MongoDB/Elasticsearch/vector connection".to_string()),
|
||||
|
||||
@@ -62,6 +62,22 @@ pub async fn mongo_drop_collection_core(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mongo_rename_collection_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
new_name: &str,
|
||||
) -> Result<(), String> {
|
||||
ensure_document_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => mongo_driver::rename_collection(client, database, collection, new_name).await,
|
||||
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support rename collection".to_string()),
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mongo_server_version_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
||||
@@ -450,6 +450,7 @@ async fn main() {
|
||||
.route("/mongo/create-database", post(routes::mongo::create_database))
|
||||
.route("/mongo/drop-database", post(routes::mongo::drop_database))
|
||||
.route("/mongo/drop-collection", post(routes::mongo::drop_collection))
|
||||
.route("/mongo/rename-collection", post(routes::mongo::rename_collection))
|
||||
.route("/document-store/list-databases", post(routes::document_store::list_databases))
|
||||
.route("/document-store/list-collections", post(routes::document_store::list_collections))
|
||||
.route("/document-store/find-documents", post(routes::document_store::find_documents))
|
||||
|
||||
@@ -77,6 +77,15 @@ pub struct MongoCollectionNameRequest {
|
||||
pub collection: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoRenameCollectionRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub collection: String,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoFindRequest {
|
||||
@@ -350,6 +359,26 @@ pub async fn drop_collection(
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn rename_collection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<MongoRenameCollectionRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Rename collection")
|
||||
.await?;
|
||||
ensure_writable(&state.app, &req.connection_id, "Rename collection").await?;
|
||||
dbx_core::mongo_ops::mongo_rename_collection_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.collection,
|
||||
&req.new_name,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn find_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -105,3 +105,31 @@ test("object browser batch empty reviews the frozen SQL plan before executing",
|
||||
assert.match(body, /runBatchTableEmpty/, "batch empty should still use the batch executor after confirmation");
|
||||
assert.ok(body.indexOf("executeObjectBrowserSqlWithProductionGuard") < body.indexOf("runBatchTableEmpty"), "production guard must be entered before the batch executor");
|
||||
});
|
||||
|
||||
test("mongo sidebar mutations share the production-gated runMongoSidebarMutation shell", () => {
|
||||
const shellSource = readSource("apps/desktop/src/lib/sidebar/runMongoSidebarMutation.ts");
|
||||
assert.match(shellSource, /executeWithProductionContextGuard/, "shared mongo mutation shell must request production confirmation");
|
||||
assert.match(shellSource, /return \{\s*result\s*\}/, "void execute success must be boxed so it is not treated as cancel");
|
||||
assert.match(shellSource, /if \(executed === undefined\) return/, "only unboxed undefined from the guard means cancel");
|
||||
assert.match(shellSource, /onSuccess\(executed\.result\)/, "onSuccess must receive the unboxed execute result");
|
||||
|
||||
const source = readSource("apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts");
|
||||
for (const name of [
|
||||
"confirmRenameMongoCollection",
|
||||
"confirmDropMongoCollection",
|
||||
"confirmDropMongoIndex",
|
||||
"confirmDropAllMongoIndexes",
|
||||
"confirmDropMongoDatabase",
|
||||
] as const) {
|
||||
const body = functionBody(source, name);
|
||||
assert.match(body, /runMongoSidebarMutation/, `${name} must use the shared mongo mutation shell`);
|
||||
assert.ok(body.includes("production.sourceSidebar"), `${name} should label the confirmation source`);
|
||||
}
|
||||
|
||||
assert.ok(source.includes("api.mongoRenameCollection"), "mongo rename should still call the rename API");
|
||||
assert.ok(source.includes("api.mongoDropDatabase"), "mongo drop database should still call the drop API");
|
||||
|
||||
const dropDatabaseBody = functionBody(readSource("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue"), "confirmDropDatabase");
|
||||
assert.match(dropDatabaseBody, /confirmDropMongoDatabase/, "host drop-database confirm should delegate mongo to the mutation runtime");
|
||||
assert.ok(!dropDatabaseBody.includes("api.mongoDropDatabase"), "host drop-database confirm should not call mongo APIs directly");
|
||||
});
|
||||
|
||||
+8
-1
@@ -34,7 +34,7 @@ test("mutation families retain accepted targets, failures, and refresh work", ()
|
||||
assert.match(body, /tableOperationFailed/);
|
||||
}
|
||||
|
||||
for (const name of ["confirmCreateNacosNamespace", "confirmEditNacosNamespace", "confirmDropDatabase", "confirmDropMongoCollection"]) {
|
||||
for (const name of ["confirmCreateNacosNamespace", "confirmEditNacosNamespace", "confirmDropDatabase"]) {
|
||||
const body = functionBody(name);
|
||||
assert.match(body, /try \{/);
|
||||
assert.match(body, /catch \([A-Za-z]+: any\)/);
|
||||
@@ -42,6 +42,13 @@ test("mutation families retain accepted targets, failures, and refresh work", ()
|
||||
assert.match(body, /Loading\.value = false/);
|
||||
}
|
||||
|
||||
// Mongo collection mutations share a production-gated shell for failures / loading.
|
||||
const dropMongoCollection = functionBody("confirmDropMongoCollection");
|
||||
assert.match(dropMongoCollection, /runMongoSidebarMutation/);
|
||||
assert.match(dropMongoCollection, /onError:\s*toastMutationError/);
|
||||
assert.match(dropMongoCollection, /api\.mongoDropCollection/);
|
||||
|
||||
|
||||
const redis = functionBody("confirmFlushRedisDb");
|
||||
assert.match(redis, /updateRedisDbKeyStats/);
|
||||
assert.match(redis, /dbx-redis-db-flushed/);
|
||||
|
||||
@@ -86,6 +86,18 @@ pub async fn mongo_drop_collection(
|
||||
dbx_core::mongo_ops::mongo_drop_collection_core(&state, &connection_id, &database, &collection).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_rename_collection(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
collection: String,
|
||||
new_name: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Rename collection").await?;
|
||||
dbx_core::mongo_ops::mongo_rename_collection_core(&state, &connection_id, &database, &collection, &new_name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn mongo_find_documents(
|
||||
|
||||
@@ -1291,6 +1291,7 @@ pub fn run() {
|
||||
commands::mongo_cmd::mongo_create_database,
|
||||
commands::mongo_cmd::mongo_drop_database,
|
||||
commands::mongo_cmd::mongo_drop_collection,
|
||||
commands::mongo_cmd::mongo_rename_collection,
|
||||
commands::document_cmd::document_list_databases,
|
||||
commands::document_cmd::document_list_collections,
|
||||
commands::document_cmd::document_find_documents,
|
||||
|
||||
Reference in New Issue
Block a user