feat(mongodb): rename collections from sidebar
This commit is contained in:
1 parent
809500fd52
commit
9a83b46f00
26 files changed
+1105
-93
No files matched your search
@@ -88,6 +88,12 @@ const {
|
||||
editNacosNamespaceDesc,
|
||||
editNacosNamespaceLoading,
|
||||
confirmEditNacosNamespace,
|
||||
showRenameMongoCollectionDialog,
|
||||
renameMongoCollectionName,
|
||||
renameMongoCollectionError,
|
||||
renameMongoCollectionPreview,
|
||||
renameMongoCollectionLoading,
|
||||
confirmRenameMongoCollection,
|
||||
showCreateSchemaDialog,
|
||||
createSchemaName,
|
||||
confirmCreateSchema,
|
||||
@@ -143,6 +149,7 @@ watch(
|
||||
showEditDatabasePropertiesDialog,
|
||||
showCreateNacosNamespaceDialog,
|
||||
showEditNacosNamespaceDialog,
|
||||
showRenameMongoCollectionDialog,
|
||||
showCreateSchemaDialog,
|
||||
showEditSchemaCommentDialog,
|
||||
],
|
||||
@@ -222,6 +229,26 @@ watch(
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showRenameMongoCollectionDialog">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3">
|
||||
<Input v-model="renameMongoCollectionName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" :disabled="renameMongoCollectionLoading" @keydown.enter.prevent="confirmRenameMongoCollection" />
|
||||
<pre v-if="renameMongoCollectionPreview" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{{ renameMongoCollectionPreview }}</pre>
|
||||
<p v-if="renameMongoCollectionError" class="text-sm text-destructive">{{ renameMongoCollectionError }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="renameMongoCollectionLoading" @click="showRenameMongoCollectionDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="renameMongoCollectionLoading || !renameMongoCollectionName || renameMongoCollectionName === node.label" @click="confirmRenameMongoCollection">
|
||||
<Loader2 v-if="renameMongoCollectionLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("contextMenu.renameObject") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showStructurePreviewDialog">
|
||||
<DialogContent class="sm:max-w-[760px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -369,6 +369,14 @@ const {
|
||||
const {
|
||||
canDropMongoDatabase,
|
||||
canDropMongoCollection,
|
||||
canRenameMongoCollection,
|
||||
prepareRenameMongoCollectionDialog,
|
||||
confirmRenameMongoCollection,
|
||||
showRenameMongoCollectionDialog,
|
||||
renameMongoCollectionName,
|
||||
renameMongoCollectionError,
|
||||
renameMongoCollectionPreview,
|
||||
renameMongoCollectionLoading,
|
||||
mongoIndexNameForNode,
|
||||
canDropMongoIndexNode,
|
||||
canDropMongoIndex,
|
||||
@@ -384,6 +392,7 @@ const {
|
||||
dropAllMongoIndexes,
|
||||
flushRedisDb,
|
||||
confirmFlushRedisDb,
|
||||
confirmDropMongoDatabase,
|
||||
confirmDropMongoCollection,
|
||||
confirmDropMongoIndex,
|
||||
confirmDropAllMongoIndexes,
|
||||
@@ -840,6 +849,10 @@ function requestRenameSelectedNode(): boolean {
|
||||
connectionStore.startEditing(editTarget.connectionId);
|
||||
return true;
|
||||
}
|
||||
if (canRenameMongoCollection.value) {
|
||||
openRenameMongoCollectionDialog();
|
||||
return true;
|
||||
}
|
||||
if (canRenameObject.value) {
|
||||
openRenameObjectDialog();
|
||||
return true;
|
||||
@@ -851,6 +864,12 @@ function requestRenameSelectedNode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function openRenameMongoCollectionDialog() {
|
||||
claimTreeItemDialogOwnership();
|
||||
routeTreeItemDialogController();
|
||||
prepareRenameMongoCollectionDialog();
|
||||
}
|
||||
|
||||
function requestEditSelectedConnection(): boolean {
|
||||
const editTarget = selectedConnectionEditTarget(activeNode.value, selectedTreeNodesInVisibleOrder());
|
||||
if (!editTarget) return false;
|
||||
@@ -2638,26 +2657,26 @@ function dropDatabase() {
|
||||
|
||||
async function confirmDropDatabase() {
|
||||
const node = sidebarDangerTarget.value ?? activeNode.value;
|
||||
if (!node.connectionId || dropDatabaseLoading.value) return;
|
||||
if (node.type === "mongo-db") {
|
||||
await confirmDropMongoDatabase();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = node.connectionId;
|
||||
if (!connectionId || dropDatabaseLoading.value) return;
|
||||
dropDatabaseLoading.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
if (node.type === "mongo-db" && node.database) {
|
||||
await api.mongoDropDatabase(node.connectionId, node.database);
|
||||
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
showDropDatabaseConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const sql =
|
||||
dropDatabasePreviewSql.value ||
|
||||
(await buildDropDatabaseSql({
|
||||
databaseType: databaseTypeForNode(node),
|
||||
name: node.label,
|
||||
}));
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
if (!executed) return;
|
||||
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
await connectionStore.loadDatabases(connectionId, { force: true });
|
||||
showDropDatabaseConfirm.value = false;
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -3316,6 +3335,12 @@ function databaseSpecificDialogCapabilities() {
|
||||
editNacosNamespaceDesc,
|
||||
editNacosNamespaceLoading,
|
||||
confirmEditNacosNamespace,
|
||||
showRenameMongoCollectionDialog,
|
||||
renameMongoCollectionName,
|
||||
renameMongoCollectionError,
|
||||
renameMongoCollectionPreview,
|
||||
renameMongoCollectionLoading,
|
||||
confirmRenameMongoCollection,
|
||||
showCreateSchemaDialog,
|
||||
createSchemaName,
|
||||
confirmCreateSchema,
|
||||
@@ -3792,6 +3817,14 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties });
|
||||
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
|
||||
if (canRenameMongoCollection.value) {
|
||||
items.push({
|
||||
label: t("contextMenu.renameObject"),
|
||||
action: openRenameMongoCollectionDialog,
|
||||
icon: Pencil,
|
||||
shortcut: shortcutRename,
|
||||
});
|
||||
}
|
||||
if (canDropAllMongoIndexes.value || canDropMongoCollection.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
if (canDropAllMongoIndexes.value) {
|
||||
|
||||
@@ -87,6 +87,11 @@ export const showDropDatabaseConfirm = ref(false);
|
||||
export const dropDatabaseLoading = ref(false);
|
||||
export const showDropMongoCollectionConfirm = ref(false);
|
||||
export const dropMongoCollectionLoading = ref(false);
|
||||
export const showRenameMongoCollectionDialog = ref(false);
|
||||
export const renameMongoCollectionName = ref("");
|
||||
export const renameMongoCollectionError = ref("");
|
||||
export const renameMongoCollectionPreview = ref("");
|
||||
export const renameMongoCollectionLoading = ref(false);
|
||||
export const showDropMongoIndexConfirm = ref(false);
|
||||
export const dropMongoIndexLoading = ref(false);
|
||||
export const showDropAllMongoIndexesConfirm = ref(false);
|
||||
@@ -130,6 +135,7 @@ const openFlags = [
|
||||
showEditNacosNamespaceDialog,
|
||||
showDropDatabaseConfirm,
|
||||
showDropMongoCollectionConfirm,
|
||||
showRenameMongoCollectionDialog,
|
||||
showDropMongoIndexConfirm,
|
||||
showDropAllMongoIndexesConfirm,
|
||||
showFlushRedisDbConfirm,
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user