feat(mongo): add dropIndex and dropIndexes support across full stack
This commit is contained in:
parent
bb72a58e54
commit
93eeeaa9c6
|
|
@ -21,6 +21,7 @@ const props = withDefaults(
|
|||
title?: string;
|
||||
message?: string;
|
||||
details?: string;
|
||||
detailsText?: string;
|
||||
confirmLabel?: string;
|
||||
showSuppressToggle?: boolean;
|
||||
suppressToggleLabel?: string;
|
||||
|
|
@ -32,6 +33,7 @@ const props = withDefaults(
|
|||
title: "",
|
||||
message: "",
|
||||
details: "",
|
||||
detailsText: "",
|
||||
confirmLabel: "",
|
||||
showSuppressToggle: false,
|
||||
suppressToggleLabel: "",
|
||||
|
|
@ -73,6 +75,7 @@ function onConfirm() {
|
|||
|
||||
<div class="py-4 min-w-0">
|
||||
<p class="text-sm text-muted-foreground mb-3">{{ message || t("dangerDialog.message") }}</p>
|
||||
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line">{{ detailsText }}</p>
|
||||
<div v-if="code" class="relative">
|
||||
<Button variant="ghost" size="icon-xs" class="absolute top-1 right-1 z-10 h-6 w-6" :class="wrap ? 'text-foreground bg-accent' : 'text-muted-foreground'" :title="t('dangerDialog.wrapLines')" @click="wrap = !wrap">
|
||||
<TextWrap class="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -7194,6 +7194,11 @@ const indexes = ref<IndexInfo[]>([]);
|
|||
const indexesLoaded = ref(false);
|
||||
const indexesLoading = ref(false);
|
||||
const indexesError = ref("");
|
||||
const showDropMongoIndexConfirm = ref(false);
|
||||
const dropMongoIndexLoading = ref(false);
|
||||
const pendingDropMongoIndex = ref<IndexInfo | null>(null);
|
||||
const showDropAllMongoIndexesConfirm = ref(false);
|
||||
const dropAllMongoIndexesLoading = ref(false);
|
||||
const foreignKeys = ref<ForeignKeyInfo[]>([]);
|
||||
const foreignKeysLoaded = ref(false);
|
||||
const foreignKeysLoading = ref(false);
|
||||
|
|
@ -7272,6 +7277,8 @@ function toggleCellDetailPanelLayout() {
|
|||
|
||||
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType));
|
||||
const canOpenTableStructureEditor = computed(() => !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsTableStructureEditing(resolvedDatabaseType.value));
|
||||
const mongoConnectionConfig = computed(() => connectionStore.getConfig(props.connectionId ?? ""));
|
||||
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && mongoConnectionConfig.value?.db_type === "mongodb" && mongoConnectionConfig.value?.driver_profile !== "mongodb-legacy");
|
||||
const tableInfoTabs = computed(() => {
|
||||
const tabs: TableInfoTabItem[] = [];
|
||||
if (tableMetadataCapabilities.value.columns) {
|
||||
|
|
@ -7359,6 +7366,11 @@ async function fetchIndexes() {
|
|||
}
|
||||
}
|
||||
|
||||
async function reloadIndexes() {
|
||||
indexesLoaded.value = false;
|
||||
await fetchIndexes();
|
||||
}
|
||||
|
||||
async function fetchForeignKeys() {
|
||||
if (!props.connectionId || !props.tableMeta || foreignKeysLoaded.value || foreignKeysLoading.value) return;
|
||||
foreignKeysLoading.value = true;
|
||||
|
|
@ -7604,6 +7616,62 @@ const filteredIndexes = computed(() => {
|
|||
return indexes.value.filter((i) => i.name.toLowerCase().includes(q) || i.columns.some((c) => c.toLowerCase().includes(q)));
|
||||
});
|
||||
|
||||
const droppableMongoIndexes = computed(() => indexes.value.filter((index) => !index.is_primary));
|
||||
const dropMongoIndexConfirmMessage = computed(() =>
|
||||
pendingDropMongoIndex.value
|
||||
? t("contextMenu.confirmDropMongoIndexMessage", {
|
||||
name: pendingDropMongoIndex.value.name,
|
||||
collection: props.tableMeta?.tableName || "",
|
||||
})
|
||||
: "",
|
||||
);
|
||||
const dropAllMongoIndexesConfirmMessage = computed(() => t("contextMenu.confirmDropMongoAllIndexesMessage", { name: props.tableMeta?.tableName || "" }));
|
||||
const dropAllMongoIndexesConfirmDetails = computed(() => t("contextMenu.confirmDropMongoAllIndexesDetails"));
|
||||
const dropMongoIndexPreview = computed(() => (pendingDropMongoIndex.value ? `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndex(${JSON.stringify(pendingDropMongoIndex.value.name)})` : ""));
|
||||
const dropAllMongoIndexesPreview = computed(() => `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndexes()`);
|
||||
|
||||
function requestDropMongoIndex(index: IndexInfo) {
|
||||
pendingDropMongoIndex.value = index;
|
||||
showDropMongoIndexConfirm.value = true;
|
||||
}
|
||||
|
||||
function requestDropAllMongoIndexes() {
|
||||
showDropAllMongoIndexesConfirm.value = true;
|
||||
}
|
||||
|
||||
async function confirmDropMongoIndex() {
|
||||
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || !pendingDropMongoIndex.value || dropMongoIndexLoading.value) return;
|
||||
dropMongoIndexLoading.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, JSON.stringify(pendingDropMongoIndex.value.name), true);
|
||||
toast(t("contextMenu.dropTableChildObjectSuccess", { name: pendingDropMongoIndex.value.name }), 3000);
|
||||
showDropMongoIndexConfirm.value = false;
|
||||
pendingDropMongoIndex.value = null;
|
||||
await reloadIndexes();
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
dropMongoIndexLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDropAllMongoIndexes() {
|
||||
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || dropAllMongoIndexesLoading.value) return;
|
||||
dropAllMongoIndexesLoading.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
const result = await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, undefined, false);
|
||||
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: props.tableMeta.tableName }), 3000);
|
||||
showDropAllMongoIndexesConfirm.value = false;
|
||||
await reloadIndexes();
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
dropAllMongoIndexesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredForeignKeys = computed(() => {
|
||||
if (!searchQuery.value) return foreignKeys.value;
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
|
|
@ -9326,6 +9394,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
<WrapText class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="activeTableInfoTab === 'indexes' && canManageMongoIndexes" class="table-info-actions flex min-w-0 shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" class="table-info-action-button h-6 px-2 text-xs text-destructive hover:text-destructive" :disabled="indexesLoading || dropAllMongoIndexesLoading || droppableMongoIndexes.length === 0" @click="requestDropAllMongoIndexes">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
<span class="table-info-action-label">{{ t("contextMenu.dropAllIndexes") }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-if="canOpenTableStructureEditor" variant="ghost" size="sm" class="table-info-action-button h-6 px-2 text-xs" :title="t('contextMenu.editStructure')" :aria-label="t('contextMenu.editStructure')" @click="openTableStructureEditor">
|
||||
<PencilRuler class="w-3 h-3" />
|
||||
<span class="table-info-action-label">{{ t("contextMenu.editStructure") }}</span>
|
||||
|
|
@ -9413,14 +9487,22 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="index in filteredIndexes" :key="index.name" class="p-3 text-xs">
|
||||
<div class="font-medium truncate">{{ index.name }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<span v-if="index.is_primary" class="rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-600">PK</span>
|
||||
<span v-if="index.is_unique" class="rounded bg-emerald-500/10 px-1.5 py-0.5 text-emerald-600">UNIQUE</span>
|
||||
<span v-if="index.index_type" class="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{{ index.index_type }}</span>
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-[11px] text-muted-foreground break-all">
|
||||
{{ index.columns.join(", ") }}
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium truncate">{{ index.name }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<span v-if="index.is_primary" class="rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-600">PK</span>
|
||||
<span v-if="index.is_unique" class="rounded bg-emerald-500/10 px-1.5 py-0.5 text-emerald-600">UNIQUE</span>
|
||||
<span v-if="index.index_type" class="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{{ index.index_type }}</span>
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-[11px] text-muted-foreground break-all">
|
||||
{{ index.columns.join(", ") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canManageMongoIndexes && !index.is_primary" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-[11px] text-destructive hover:text-destructive" @click="requestDropMongoIndex(index)">
|
||||
<Trash2 class="mr-1 h-3 w-3" />
|
||||
{{ t("contextMenu.dropIndex") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -10143,6 +10225,27 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
:confirm-label="pendingDeleteRowIds.length > 1 ? t('grid.deleteRows', { count: pendingDeleteRowIds.length }) : t('grid.deleteRow')"
|
||||
@confirm="confirmDeleteRow"
|
||||
/>
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDropMongoIndexConfirm"
|
||||
:title="t('contextMenu.confirmDropIndexTitle')"
|
||||
:message="dropMongoIndexConfirmMessage"
|
||||
:details="dropMongoIndexPreview"
|
||||
:confirm-label="t('contextMenu.dropIndex')"
|
||||
:loading="dropMongoIndexLoading"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDropMongoIndex"
|
||||
/>
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDropAllMongoIndexesConfirm"
|
||||
:title="t('contextMenu.dropAllIndexes')"
|
||||
:message="dropAllMongoIndexesConfirmMessage"
|
||||
:details-text="dropAllMongoIndexesConfirmDetails"
|
||||
:sql="dropAllMongoIndexesPreview"
|
||||
:confirm-label="t('contextMenu.dropAllIndexes')"
|
||||
:loading="dropAllMongoIndexesLoading"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDropAllMongoIndexes"
|
||||
/>
|
||||
<ImagePreviewDialog v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
|
||||
<component v-if="previewDialogOpen && previewDialogConfig" :is="previewDialogConfig.component" v-model:open="previewDialogOpen" v-bind="previewDialogConfig.props" />
|
||||
<ExportProgressDialog v-model:open="exportProgressDialog" v-bind="exportProgressState" :disable-cancel="!exportCancelHandler" @cancel="cancelActiveExport" />
|
||||
|
|
|
|||
|
|
@ -1528,6 +1528,10 @@ const showDropDatabaseConfirm = ref(false);
|
|||
const dropDatabaseLoading = ref(false);
|
||||
const showDropMongoCollectionConfirm = ref(false);
|
||||
const dropMongoCollectionLoading = ref(false);
|
||||
const showDropMongoIndexConfirm = ref(false);
|
||||
const dropMongoIndexLoading = ref(false);
|
||||
const showDropAllMongoIndexesConfirm = ref(false);
|
||||
const dropAllMongoIndexesLoading = ref(false);
|
||||
const showFlushRedisDbConfirm = ref(false);
|
||||
const showCreateSchemaDialog = ref(false);
|
||||
const createSchemaName = ref("");
|
||||
|
|
@ -1756,6 +1760,7 @@ function canDropTreeNode(node: TreeNode): boolean {
|
|||
if (node.type === "view" || node.type === "materialized_view" || node.type === "procedure" || node.type === "function") {
|
||||
return !!node.connectionId && !!node.database && !!dropObjectSqlOptionsForNode(node);
|
||||
}
|
||||
if (canDropMongoIndexNode(node)) return true;
|
||||
return canDropTableChildObjectNode(node);
|
||||
}
|
||||
|
||||
|
|
@ -1770,16 +1775,41 @@ function selectedBatchDropTargets(): TreeNode[] {
|
|||
return selected;
|
||||
}
|
||||
|
||||
function selectedBatchMongoIndexTargets(): TreeNode[] {
|
||||
const targets = selectedBatchDropTargets();
|
||||
return targets.length > 1 && targets.every((node) => canDropMongoIndexNode(node)) ? targets : [];
|
||||
}
|
||||
|
||||
function selectedBatchIndexTableName(targets: TreeNode[]): string | null {
|
||||
const first = targets[0];
|
||||
if (!first) return null;
|
||||
const table = first.tableName || first.label;
|
||||
return table && targets.every((node) => (node.tableName || node.label) === table) ? table : null;
|
||||
}
|
||||
|
||||
function batchDropMenuLabel(): string {
|
||||
return t("contextMenu.batchDrop", { count: selectedBatchDropTargets().length });
|
||||
const targets = selectedBatchDropTargets();
|
||||
if (targets.length > 1 && targets.every((node) => node.type === "index")) {
|
||||
return t("contextMenu.batchDropIndexes", { count: targets.length });
|
||||
}
|
||||
return t("contextMenu.batchDrop", { count: targets.length });
|
||||
}
|
||||
|
||||
function batchDropConfirmTitle(): string {
|
||||
return t("contextMenu.confirmBatchDropTitle", { count: selectedBatchDropTargets().length });
|
||||
const targets = selectedBatchDropTargets();
|
||||
if (targets.length > 1 && targets.every((node) => node.type === "index")) {
|
||||
return t("contextMenu.confirmDropIndexTitle");
|
||||
}
|
||||
return t("contextMenu.confirmBatchDropTitle", { count: targets.length });
|
||||
}
|
||||
|
||||
function batchDropConfirmMessage(): string {
|
||||
return t("contextMenu.confirmBatchDropMessage", { count: selectedBatchDropTargets().length });
|
||||
const targets = selectedBatchDropTargets();
|
||||
const table = selectedBatchIndexTableName(targets);
|
||||
if (targets.length > 1 && targets.every((node) => node.type === "index") && table) {
|
||||
return t("contextMenu.confirmDropBatchIndexesMessage", { count: targets.length, table });
|
||||
}
|
||||
return t("contextMenu.confirmBatchDropMessage", { count: targets.length });
|
||||
}
|
||||
|
||||
async function dropSqlForTreeNode(node: TreeNode): Promise<string | null> {
|
||||
|
|
@ -1792,6 +1822,9 @@ async function dropSqlForTreeNode(node: TreeNode): Promise<string | null> {
|
|||
}
|
||||
const objectOptions = dropObjectSqlOptionsForNode(node);
|
||||
if (objectOptions) return buildDropObjectSql(objectOptions);
|
||||
if (canDropMongoIndexNode(node)) {
|
||||
return `db.getCollection("${(node.tableName || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}").dropIndex(${JSON.stringify(mongoIndexNameForNode(node))})`;
|
||||
}
|
||||
const childOptions = dropTableChildObjectSqlOptionsForNode(node);
|
||||
if (childOptions && canDropTableChildObjectNode(node)) return buildDropTableChildObjectSql(childOptions);
|
||||
return null;
|
||||
|
|
@ -1799,6 +1832,11 @@ async function dropSqlForTreeNode(node: TreeNode): Promise<string | null> {
|
|||
|
||||
async function refreshBatchDropPreviewSql() {
|
||||
const targets = selectedBatchDropTargets();
|
||||
const mongoIndexTargets = selectedBatchMongoIndexTargets();
|
||||
if (mongoIndexTargets.length) {
|
||||
batchDropPreviewSql.value = mongoIndexTargets.map((target) => mongoIndexDropPreview(target, mongoIndexNameForNode(target))).join("\n");
|
||||
return;
|
||||
}
|
||||
const statements: string[] = [];
|
||||
for (const target of targets) {
|
||||
const sql = await dropSqlForTreeNode(target);
|
||||
|
|
@ -1832,6 +1870,10 @@ function requestDropSelectedNode(): boolean {
|
|||
requestDropObject();
|
||||
return true;
|
||||
}
|
||||
if (canDropMongoIndex.value) {
|
||||
dropMongoIndex();
|
||||
return true;
|
||||
}
|
||||
if (canDropTableChildObject.value) {
|
||||
requestDropTableChildObject();
|
||||
return true;
|
||||
|
|
@ -1974,6 +2016,32 @@ async function confirmBatchDrop() {
|
|||
const targets = selectedBatchDropTargets();
|
||||
if (!targets.length) return;
|
||||
try {
|
||||
const mongoIndexTargets = selectedBatchMongoIndexTargets();
|
||||
if (mongoIndexTargets.length) {
|
||||
const grouped = new Map<string, TreeNode[]>();
|
||||
for (const target of mongoIndexTargets) {
|
||||
const key = `${target.connectionId}:${target.database}:${target.tableName || ""}`;
|
||||
const list = grouped.get(key) ?? [];
|
||||
list.push(target);
|
||||
grouped.set(key, list);
|
||||
}
|
||||
let droppedCount = 0;
|
||||
for (const groupTargets of grouped.values()) {
|
||||
const first = groupTargets[0];
|
||||
if (!first?.connectionId || !first.database || !first.tableName) continue;
|
||||
await connectionStore.ensureConnected(first.connectionId);
|
||||
const names = groupTargets.map((target) => mongoIndexNameForNode(target));
|
||||
const result = await api.mongoDropIndexes(first.connectionId, first.database, first.tableName, JSON.stringify(names.length === 1 ? names[0] : names), false);
|
||||
const dropped = new Set(result.dropped_names);
|
||||
droppedCount += result.dropped_names.length;
|
||||
for (const target of groupTargets) {
|
||||
if (dropped.has(mongoIndexNameForNode(target))) connectionStore.removeTreeNode(target.id);
|
||||
}
|
||||
}
|
||||
toast(t("contextMenu.batchDropSuccess", { count: droppedCount }), 3000);
|
||||
showBatchDropConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (!target.connectionId || !target.database) continue;
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
|
|
@ -2041,6 +2109,32 @@ const canDropMongoCollection = computed(() => {
|
|||
return props.node.type === "mongo-collection" && !!props.node.database && config?.driver_profile !== "mongodb-legacy";
|
||||
});
|
||||
|
||||
function mongoIndexNameForNode(node: TreeNode): string {
|
||||
if (node.type !== "index") return "";
|
||||
return node.meta && "name" in node.meta ? node.meta.name : node.label.replace(/\s+\(.+\)$/, "");
|
||||
}
|
||||
|
||||
function canDropMongoIndexNode(node: TreeNode): boolean {
|
||||
if (node.type !== "index" || !node.connectionId || !node.database || !node.tableName) return false;
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
return config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy" && mongoIndexNameForNode(node) !== "_id_";
|
||||
}
|
||||
|
||||
const canDropMongoIndex = computed(() => canDropMongoIndexNode(props.node));
|
||||
|
||||
function mongoIndexDropPreview(node: Pick<TreeNode, "tableName">, indexName: string): string {
|
||||
return `db.getCollection(${JSON.stringify(node.tableName || "")}).dropIndex(${JSON.stringify(indexName)})`;
|
||||
}
|
||||
|
||||
const canDropAllMongoIndexes = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "mongo-collection" && !!props.node.database && config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy";
|
||||
});
|
||||
|
||||
function mongoDropAllIndexesPreview(node: Pick<TreeNode, "label">): string {
|
||||
return `db.getCollection(${JSON.stringify(node.label)}).dropIndexes()`;
|
||||
}
|
||||
|
||||
const canCreateSchema = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "database" && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config);
|
||||
|
|
@ -2417,6 +2511,16 @@ function dropMongoCollection() {
|
|||
showDropMongoCollectionConfirm.value = true;
|
||||
}
|
||||
|
||||
function dropMongoIndex() {
|
||||
dropMongoIndexLoading.value = false;
|
||||
showDropMongoIndexConfirm.value = true;
|
||||
}
|
||||
|
||||
function dropAllMongoIndexes() {
|
||||
dropAllMongoIndexesLoading.value = false;
|
||||
showDropAllMongoIndexesConfirm.value = true;
|
||||
}
|
||||
|
||||
function flushRedisDb() {
|
||||
showFlushRedisDbConfirm.value = true;
|
||||
}
|
||||
|
|
@ -2486,6 +2590,53 @@ async function confirmDropMongoCollection() {
|
|||
}
|
||||
}
|
||||
|
||||
function mongoIndexesGroupNodeId(node: Pick<TreeNode, "connectionId" | "database" | "schema" | "tableName" | "label">): string | null {
|
||||
if (!node.connectionId || !node.database) return null;
|
||||
const tableName = node.tableName || node.label;
|
||||
return node.schema ? `${node.connectionId}:${node.database}:${node.schema}:${tableName}:__indexes` : `${node.connectionId}:${node.database}:${tableName}:__indexes`;
|
||||
}
|
||||
|
||||
async function refreshMongoIndexTree(node: Pick<TreeNode, "connectionId" | "database" | "schema" | "tableName" | "label">) {
|
||||
const nodeId = mongoIndexesGroupNodeId(node);
|
||||
if (!node.connectionId || !node.database || !nodeId) return;
|
||||
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName || node.label, node.schema, nodeId);
|
||||
}
|
||||
|
||||
async function confirmDropMongoIndex() {
|
||||
const node = props.node;
|
||||
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 (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
dropMongoIndexLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDropAllMongoIndexes() {
|
||||
const node = props.node;
|
||||
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 (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
dropAllMongoIndexesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateSchemaDialog() {
|
||||
createSchemaName.value = "";
|
||||
showCreateSchemaDialog.value = true;
|
||||
|
|
@ -3958,9 +4109,14 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
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 (canDropMongoCollection.value) {
|
||||
if (canDropAllMongoIndexes.value || canDropMongoCollection.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
|
||||
if (canDropAllMongoIndexes.value) {
|
||||
items.push({ label: t("contextMenu.dropAllIndexes"), action: dropAllMongoIndexes, icon: Trash2, variant: "destructive" as const });
|
||||
}
|
||||
if (canDropMongoCollection.value) {
|
||||
items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
|
@ -4108,7 +4264,16 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
|
||||
if (node.type === "index" || node.type === "fkey" || node.type === "trigger") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
if (canDropTableChildObject.value) {
|
||||
if (node.type === "index" && canDropMongoIndex.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: deleteMenuLabel(t("contextMenu.dropIndex")),
|
||||
action: deleteMenuAction(dropMongoIndex),
|
||||
icon: Trash2,
|
||||
shortcut: shortcutDelete,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
} else if (canDropTableChildObject.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: deleteMenuLabel(dropTableChildObjectMenuLabel()),
|
||||
|
|
@ -4625,6 +4790,29 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
@confirm="confirmDropMongoCollection"
|
||||
/>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDropMongoIndexConfirm"
|
||||
:title="t('contextMenu.confirmDropIndexTitle')"
|
||||
:message="t('contextMenu.confirmDropMongoIndexMessage', { name: mongoIndexNameForNode(node), collection: node.tableName || '' })"
|
||||
:details="mongoIndexDropPreview(node, mongoIndexNameForNode(node))"
|
||||
:confirm-label="t('contextMenu.dropIndex')"
|
||||
:loading="dropMongoIndexLoading"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDropMongoIndex"
|
||||
/>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDropAllMongoIndexesConfirm"
|
||||
:title="t('contextMenu.dropAllIndexes')"
|
||||
:message="t('contextMenu.confirmDropMongoAllIndexesMessage', { name: node.label })"
|
||||
:details-text="t('contextMenu.confirmDropMongoAllIndexesDetails')"
|
||||
:sql="mongoDropAllIndexesPreview(node)"
|
||||
:confirm-label="t('contextMenu.dropAllIndexes')"
|
||||
:loading="dropAllMongoIndexesLoading"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDropAllMongoIndexes"
|
||||
/>
|
||||
|
||||
<DangerConfirmDialog v-model:open="showFlushRedisDbConfirm" :title="t('redis.flushDb')" :message="t('redis.flushDbMessage')" :details="t('redis.flushDbDetails', { db: node.database })" :confirm-label="t('redis.flushDbConfirm')" @confirm="confirmFlushRedisDb" />
|
||||
|
||||
<Dialog v-model:open="showCreateSchemaDialog">
|
||||
|
|
|
|||
|
|
@ -1290,9 +1290,11 @@ export default {
|
|||
dropView: "Drop View",
|
||||
dropColumn: "Drop Column",
|
||||
dropIndex: "Drop Index",
|
||||
dropAllIndexes: "Drop All Indexes",
|
||||
dropForeignKey: "Drop Foreign Key",
|
||||
dropTrigger: "Drop Trigger",
|
||||
batchDrop: "Drop selected ({count})",
|
||||
batchDropIndexes: "Drop Indexes ({count})",
|
||||
executeProcedure: "Execute Procedure",
|
||||
confirmExecuteProcedureTitle: "Execute Procedure",
|
||||
confirmExecuteProcedureMessage: 'Execute procedure "{name}"? You can fill or adjust parameter values first.',
|
||||
|
|
@ -1321,9 +1323,13 @@ export default {
|
|||
confirmDropObjectMessage: 'Are you sure you want to drop "{name}"?',
|
||||
confirmDropColumnTitle: "Drop Column",
|
||||
confirmDropIndexTitle: "Drop Index",
|
||||
confirmDropMongoIndexMessage: 'Are you sure you want to drop index "{name}" from collection "{collection}"?',
|
||||
confirmDropMongoAllIndexesMessage: 'Are you sure you want to drop all removable indexes from collection "{name}"?',
|
||||
confirmDropMongoAllIndexesDetails: 'MongoDB keeps the "_id_" index. Additional shard-key-required indexes may also remain; the result will list only the indexes actually dropped.',
|
||||
confirmDropForeignKeyTitle: "Drop Foreign Key",
|
||||
confirmDropTriggerTitle: "Drop Trigger",
|
||||
confirmDropTableChildObjectMessage: 'Are you sure you want to drop "{name}" from "{table}"?',
|
||||
confirmDropBatchIndexesMessage: 'Are you sure you want to drop {count} selected indexes from "{table}"? This cannot be undone.',
|
||||
confirmBatchDropTitle: "Drop Selected Objects",
|
||||
confirmBatchDropMessage: "Are you sure you want to drop {count} selected objects? This cannot be undone.",
|
||||
confirmDropProcedureTitle: "Drop Procedure",
|
||||
|
|
@ -1334,6 +1340,7 @@ export default {
|
|||
dropProcedureSuccess: 'Procedure "{name}" dropped',
|
||||
dropFunctionSuccess: 'Function "{name}" dropped',
|
||||
dropTableChildObjectSuccess: '"{name}" dropped',
|
||||
dropAllIndexesSuccess: 'Dropped {count} indexes from "{name}"',
|
||||
batchDropSuccess: "Dropped {count} objects",
|
||||
emptyTableSuccess: 'All data deleted from "{name}"',
|
||||
truncateTableSuccess: 'Table "{name}" truncated',
|
||||
|
|
|
|||
|
|
@ -1297,15 +1297,21 @@ export default withEnglishFallback({
|
|||
openInSqlEditor: "Abrir en editor",
|
||||
dropProcedure: "Eliminar procedimiento",
|
||||
dropFunction: "Eliminar función",
|
||||
dropAllIndexes: "Eliminar todos los índices",
|
||||
batchDropIndexes: "Eliminar índices ({count})",
|
||||
confirmDropViewTitle: "Eliminar vista",
|
||||
confirmDropViewMessage: '¿Seguro que deseas eliminar la vista "{name}"?',
|
||||
confirmDropObjectTitle: "Eliminar objeto",
|
||||
confirmDropObjectMessage: '¿Seguro que deseas eliminar "{name}"?',
|
||||
confirmDropColumnTitle: "Eliminar columna",
|
||||
confirmDropIndexTitle: "Eliminar índice",
|
||||
confirmDropMongoIndexMessage: '¿Seguro que deseas eliminar el índice "{name}" de la colección "{collection}"?',
|
||||
confirmDropMongoAllIndexesMessage: '¿Seguro que deseas eliminar todos los índices removibles de la colección "{name}"?',
|
||||
confirmDropMongoAllIndexesDetails: 'MongoDB conserva el índice "_id_". También pueden mantenerse índices adicionales requeridos por shard keys; el resultado solo mostrará los índices realmente eliminados.',
|
||||
confirmDropForeignKeyTitle: "Eliminar clave foránea",
|
||||
confirmDropTriggerTitle: "Eliminar disparador",
|
||||
confirmDropTableChildObjectMessage: '¿Seguro que deseas eliminar "{name}" de "{table}"?',
|
||||
confirmDropBatchIndexesMessage: '¿Seguro que deseas eliminar {count} índices seleccionados de "{table}"? Esta acción no se puede deshacer.',
|
||||
confirmBatchDropTitle: "Eliminar objetos seleccionados",
|
||||
confirmBatchDropMessage: "¿Seguro que deseas eliminar {count} objetos seleccionados? Esta acción no se puede deshacer.",
|
||||
confirmDropProcedureTitle: "Eliminar procedimiento",
|
||||
|
|
@ -1316,6 +1322,7 @@ export default withEnglishFallback({
|
|||
dropProcedureSuccess: 'Procedimiento "{name}" eliminado',
|
||||
dropFunctionSuccess: 'Función "{name}" eliminada',
|
||||
dropTableChildObjectSuccess: '"{name}" eliminado',
|
||||
dropAllIndexesSuccess: 'Se eliminaron {count} índices de "{name}"',
|
||||
batchDropSuccess: "{count} objetos eliminados",
|
||||
emptyTableSuccess: 'Todos los datos eliminados de "{name}"',
|
||||
truncateTableSuccess: 'Tabla "{name}" truncada',
|
||||
|
|
|
|||
|
|
@ -1291,9 +1291,11 @@ export default withEnglishFallback({
|
|||
dropView: "Elimina Vista",
|
||||
dropColumn: "Elimina Colonna",
|
||||
dropIndex: "Elimina Indice",
|
||||
dropAllIndexes: "Elimina tutti gli indici",
|
||||
dropForeignKey: "Elimina Chiave Esterna",
|
||||
dropTrigger: "Elimina Trigger",
|
||||
batchDrop: "Elimina selezionati ({count})",
|
||||
batchDropIndexes: "Elimina indici ({count})",
|
||||
executeProcedure: "Esegui Procedura",
|
||||
confirmExecuteProcedureTitle: "Esegui Procedura",
|
||||
confirmExecuteProcedureMessage: 'Eseguire la procedura "{name}"? È possibile compilare o regolare i valori dei parametri prima.',
|
||||
|
|
@ -1322,9 +1324,13 @@ export default withEnglishFallback({
|
|||
confirmDropObjectMessage: 'Sei sicuro di voler eliminare "{name}"?',
|
||||
confirmDropColumnTitle: "Elimina Colonna",
|
||||
confirmDropIndexTitle: "Elimina Indice",
|
||||
confirmDropMongoIndexMessage: 'Vuoi eliminare l\'indice "{name}" dalla collection "{collection}"?',
|
||||
confirmDropMongoAllIndexesMessage: 'Vuoi eliminare tutti gli indici rimovibili dalla collection "{name}"?',
|
||||
confirmDropMongoAllIndexesDetails: 'MongoDB mantiene l\'indice "_id_". Altri indici richiesti dalla shard key potrebbero restare; il risultato elencherà solo quelli effettivamente eliminati.',
|
||||
confirmDropForeignKeyTitle: "Elimina Chiave Esterna",
|
||||
confirmDropTriggerTitle: "Elimina Trigger",
|
||||
confirmDropTableChildObjectMessage: 'Sei sicuro di voler eliminare "{name}" da "{table}"?',
|
||||
confirmDropBatchIndexesMessage: 'Sei sicuro di voler eliminare {count} indici selezionati da "{table}"? Questa azione non può essere annullata.',
|
||||
confirmBatchDropTitle: "Elimina Oggetti Selezionati",
|
||||
confirmBatchDropMessage: "Sei sicuro di voler eliminare {count} oggetti selezionati? Questa azione non può essere annullata.",
|
||||
confirmDropProcedureTitle: "Elimina Procedura",
|
||||
|
|
@ -1335,6 +1341,7 @@ export default withEnglishFallback({
|
|||
dropProcedureSuccess: 'Procedura "{name}" eliminata',
|
||||
dropFunctionSuccess: 'Funzione "{name}" eliminata',
|
||||
dropTableChildObjectSuccess: '"{name}" eliminato',
|
||||
dropAllIndexesSuccess: 'Eliminati {count} indici da "{name}"',
|
||||
batchDropSuccess: "Eliminati {count} oggetti",
|
||||
emptyTableSuccess: 'Tutti i dati eliminati da "{name}"',
|
||||
truncateTableSuccess: 'Tabella "{name}" troncata',
|
||||
|
|
|
|||
|
|
@ -1294,15 +1294,21 @@ export default withEnglishFallback({
|
|||
openInSqlEditor: "エディタで開く",
|
||||
dropProcedure: "プロシージャを削除",
|
||||
dropFunction: "関数を削除",
|
||||
dropAllIndexes: "すべてのインデックスを削除",
|
||||
batchDropIndexes: "インデックスを削除 ({count})",
|
||||
confirmDropViewTitle: "ビューを削除",
|
||||
confirmDropViewMessage: "本当にビュー「{name}」を削除しますか?",
|
||||
confirmDropObjectTitle: "オブジェクトを削除",
|
||||
confirmDropObjectMessage: "本当に「{name}」を削除しますか?",
|
||||
confirmDropColumnTitle: "列を削除",
|
||||
confirmDropIndexTitle: "インデックスを削除",
|
||||
confirmDropMongoIndexMessage: "コレクション「{collection}」からインデックス「{name}」を削除しますか?",
|
||||
confirmDropMongoAllIndexesMessage: "コレクション「{name}」から削除可能なインデックスをすべて削除しますか?",
|
||||
confirmDropMongoAllIndexesDetails: "MongoDB は「_id_」インデックスを保持します。shard key の制約で追加のインデックスが残る場合があり、結果には実際に削除されたインデックスのみ表示されます。",
|
||||
confirmDropForeignKeyTitle: "外部キーを削除",
|
||||
confirmDropTriggerTitle: "トリガーを削除",
|
||||
confirmDropTableChildObjectMessage: "本当に「{table}」から「{name}」を削除しますか?",
|
||||
confirmDropBatchIndexesMessage: "本当に「{table}」から選択した {count} 個のインデックスを削除しますか?この操作は取り消せません。",
|
||||
confirmBatchDropTitle: "選択したオブジェクトを削除",
|
||||
confirmBatchDropMessage: "選択した{count}個のオブジェクトを削除しますか?この操作は取り消せません。",
|
||||
confirmDropProcedureTitle: "プロシージャを削除",
|
||||
|
|
@ -1313,6 +1319,7 @@ export default withEnglishFallback({
|
|||
dropProcedureSuccess: "プロシージャ「{name}」を削除しました",
|
||||
dropFunctionSuccess: "関数「{name}」を削除しました",
|
||||
dropTableChildObjectSuccess: "「{name}」を削除しました",
|
||||
dropAllIndexesSuccess: "「{name}」から {count} 個のインデックスを削除しました",
|
||||
batchDropSuccess: "{count}個のオブジェクトを削除しました",
|
||||
emptyTableSuccess: "「{name}」のすべてのデータを削除しました",
|
||||
truncateTableSuccess: "テーブル「{name}」をトランケートしました",
|
||||
|
|
|
|||
|
|
@ -1309,15 +1309,21 @@ export default withEnglishFallback({
|
|||
openInSqlEditor: "Abrir no Editor",
|
||||
dropProcedure: "Remover Procedimento",
|
||||
dropFunction: "Remover Função",
|
||||
dropAllIndexes: "Remover todos os índices",
|
||||
batchDropIndexes: "Remover índices ({count})",
|
||||
confirmDropViewTitle: "Remover Visão",
|
||||
confirmDropViewMessage: 'Tem certeza de que deseja remover a visão "{name}"?',
|
||||
confirmDropObjectTitle: "Remover Objeto",
|
||||
confirmDropObjectMessage: 'Tem certeza de que deseja remover "{name}"?',
|
||||
confirmDropColumnTitle: "Remover Coluna",
|
||||
confirmDropIndexTitle: "Remover Índice",
|
||||
confirmDropMongoIndexMessage: 'Tem certeza de que deseja remover o índice "{name}" da coleção "{collection}"?',
|
||||
confirmDropMongoAllIndexesMessage: 'Tem certeza de que deseja remover todos os índices removíveis da coleção "{name}"?',
|
||||
confirmDropMongoAllIndexesDetails: 'O MongoDB mantém o índice "_id_". Índices adicionais exigidos por shard key também podem permanecer; o resultado listará apenas os índices realmente removidos.',
|
||||
confirmDropForeignKeyTitle: "Remover Chave Estrangeira",
|
||||
confirmDropTriggerTitle: "Remover Gatilho",
|
||||
confirmDropTableChildObjectMessage: 'Tem certeza de que deseja remover "{name}" de "{table}"?',
|
||||
confirmDropBatchIndexesMessage: 'Tem certeza de que deseja remover {count} índices selecionados de "{table}"? Esta ação não pode ser desfeita.',
|
||||
confirmBatchDropTitle: "Remover Objetos Selecionados",
|
||||
confirmBatchDropMessage: "Tem certeza de que deseja remover {count} objetos selecionados? Esta ação não pode ser desfeita.",
|
||||
confirmDropProcedureTitle: "Remover Procedimento",
|
||||
|
|
@ -1328,6 +1334,7 @@ export default withEnglishFallback({
|
|||
dropProcedureSuccess: 'Procedimento "{name}" removido',
|
||||
dropFunctionSuccess: 'Função "{name}" removida',
|
||||
dropTableChildObjectSuccess: '"{name}" removido',
|
||||
dropAllIndexesSuccess: 'Removidos {count} índices de "{name}"',
|
||||
batchDropSuccess: "{count} objetos removidos",
|
||||
emptyTableSuccess: 'Todos os dados excluídos de "{name}"',
|
||||
truncateTableSuccess: 'Tabela "{name}" truncada',
|
||||
|
|
|
|||
|
|
@ -1293,9 +1293,11 @@ export default withEnglishFallback({
|
|||
dropView: "删除视图",
|
||||
dropColumn: "删除字段",
|
||||
dropIndex: "删除索引",
|
||||
dropAllIndexes: "删除全部索引",
|
||||
dropForeignKey: "删除外键",
|
||||
dropTrigger: "删除触发器",
|
||||
batchDrop: "删除所选({count})",
|
||||
batchDropIndexes: "删除索引({count})",
|
||||
executeProcedure: "执行过程",
|
||||
confirmExecuteProcedureTitle: "执行存储过程",
|
||||
confirmExecuteProcedureMessage: "确认执行存储过程「{name}」?可先补充或调整参数值。",
|
||||
|
|
@ -1324,9 +1326,13 @@ export default withEnglishFallback({
|
|||
confirmDropObjectMessage: "确定要删除「{name}」吗?",
|
||||
confirmDropColumnTitle: "删除字段",
|
||||
confirmDropIndexTitle: "删除索引",
|
||||
confirmDropMongoIndexMessage: "确定要从集合「{collection}」删除索引「{name}」吗?",
|
||||
confirmDropMongoAllIndexesMessage: "确定要删除集合「{name}」中所有可删除的索引吗?",
|
||||
confirmDropMongoAllIndexesDetails: "MongoDB 会保留 “_id_” 索引。若服务端因 shard key 规则保留了额外索引,最终结果将仅列出实际删除的索引。",
|
||||
confirmDropForeignKeyTitle: "删除外键",
|
||||
confirmDropTriggerTitle: "删除触发器",
|
||||
confirmDropTableChildObjectMessage: "确定要从「{table}」删除「{name}」吗?",
|
||||
confirmDropBatchIndexesMessage: "确定要从「{table}」删除已选择的 {count} 个索引吗?此操作不可撤销。",
|
||||
confirmBatchDropTitle: "删除所选对象",
|
||||
confirmBatchDropMessage: "确定要删除已选择的 {count} 个对象吗?此操作不可撤销。",
|
||||
confirmDropProcedureTitle: "删除存储过程",
|
||||
|
|
@ -1337,6 +1343,7 @@ export default withEnglishFallback({
|
|||
dropProcedureSuccess: "存储过程「{name}」已删除",
|
||||
dropFunctionSuccess: "函数「{name}」已删除",
|
||||
dropTableChildObjectSuccess: "「{name}」已删除",
|
||||
dropAllIndexesSuccess: "已从「{name}」删除 {count} 个索引",
|
||||
batchDropSuccess: "已删除 {count} 个对象",
|
||||
emptyTableSuccess: "已清空「{name}」的所有数据",
|
||||
truncateTableSuccess: "表「{name}」已截断",
|
||||
|
|
|
|||
|
|
@ -1298,15 +1298,21 @@ export default withEnglishFallback({
|
|||
openInSqlEditor: "在編輯器中開啟",
|
||||
dropProcedure: "刪除預存程序",
|
||||
dropFunction: "刪除函式",
|
||||
dropAllIndexes: "刪除全部索引",
|
||||
batchDropIndexes: "刪除索引({count})",
|
||||
confirmDropViewTitle: "刪除檢視",
|
||||
confirmDropViewMessage: "確定要刪除檢視「{name}」嗎?",
|
||||
confirmDropObjectTitle: "刪除物件",
|
||||
confirmDropObjectMessage: "確定要刪除「{name}」嗎?",
|
||||
confirmDropColumnTitle: "刪除欄位",
|
||||
confirmDropIndexTitle: "刪除索引",
|
||||
confirmDropMongoIndexMessage: "確定要從集合「{collection}」刪除索引「{name}」嗎?",
|
||||
confirmDropMongoAllIndexesMessage: "確定要刪除集合「{name}」中所有可刪除的索引嗎?",
|
||||
confirmDropMongoAllIndexesDetails: "MongoDB 會保留「_id_」索引。若服務端因 shard key 規則保留了額外索引,最終結果只會列出實際刪除的索引。",
|
||||
confirmDropForeignKeyTitle: "刪除外鍵",
|
||||
confirmDropTriggerTitle: "刪除觸發器",
|
||||
confirmDropTableChildObjectMessage: "確定要從「{table}」刪除「{name}」嗎?",
|
||||
confirmDropBatchIndexesMessage: "確定要從「{table}」刪除已選擇的 {count} 個索引嗎?此操作無法復原。",
|
||||
confirmBatchDropTitle: "刪除所選物件",
|
||||
confirmBatchDropMessage: "確定要刪除已選擇的 {count} 個物件嗎?此操作無法復原。",
|
||||
confirmDropProcedureTitle: "刪除預存程序",
|
||||
|
|
@ -1317,6 +1323,7 @@ export default withEnglishFallback({
|
|||
dropProcedureSuccess: "預存程序「{name}」已刪除",
|
||||
dropFunctionSuccess: "函式「{name}」已刪除",
|
||||
dropTableChildObjectSuccess: "「{name}」已刪除",
|
||||
dropAllIndexesSuccess: "已從「{name}」刪除 {count} 個索引",
|
||||
batchDropSuccess: "已刪除 {count} 個物件",
|
||||
emptyTableSuccess: "已清空「{name}」的所有資料",
|
||||
truncateTableSuccess: "資料表「{name}」已截斷",
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ export const mongoFindDocuments = forward("mongoFindDocuments");
|
|||
export const mongoServerVersion = forward("mongoServerVersion");
|
||||
export const mongoAggregateDocuments = forward("mongoAggregateDocuments");
|
||||
export const mongoCreateIndex = forward("mongoCreateIndex");
|
||||
export const mongoDropIndexes = forward("mongoDropIndexes");
|
||||
export const documentInsertDocument = forward("documentInsertDocument");
|
||||
export const mongoInsertDocument = forward("mongoInsertDocument");
|
||||
export const mongoInsertDocuments = forward("mongoInsertDocuments");
|
||||
|
|
|
|||
|
|
@ -1772,6 +1772,10 @@ export async function mongoCreateIndex(connectionId: string, database: string, c
|
|||
return post("/api/mongo/create-index", { connectionId, database, collection, keysJson, optionsJson });
|
||||
}
|
||||
|
||||
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> {
|
||||
return post("/api/mongo/drop-indexes", { connectionId, database, collection, indexesJson, single });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
|
||||
return documentInsertDocument(connectionId, database, collection, docJson);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ const COLLECTION_METHODS = [
|
|||
{ label: "deleteMany", detail: "Delete all matching documents", apply: "deleteMany({})" },
|
||||
{ label: "getIndexes", detail: "List collection indexes", apply: "getIndexes()" },
|
||||
{ label: "createIndex", detail: "Create an index", apply: "createIndex({ ${field}: 1 })" },
|
||||
{ label: "dropIndex", detail: "Drop one index", apply: 'dropIndex("${indexName}")' },
|
||||
{ label: "dropIndexes", detail: "Drop collection indexes", apply: "dropIndexes()" },
|
||||
] as const;
|
||||
|
||||
const CURSOR_METHODS = [
|
||||
|
|
@ -66,7 +68,7 @@ const FIELD_SNIPPETS = [
|
|||
const QUERY_OPERATORS = ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$exists", "$regex", "$and", "$or", "$nor", "$not", "$elemMatch"];
|
||||
const UPDATE_OPERATORS = ["$set", "$unset", "$inc", "$push", "$pull", "$addToSet", "$rename", "$currentDate", "$setOnInsert"];
|
||||
const PIPELINE_STAGES = ["$match", "$project", "$group", "$sort", "$limit", "$skip", "$unwind", "$lookup", "$addFields", "$count", "$facet"];
|
||||
const QUERY_METHODS = ["find", "findOne", "countDocuments", "updateOne", "updateMany", "deleteOne", "deleteMany", "sort"];
|
||||
const QUERY_METHODS = ["find", "findOne", "countDocuments", "updateOne", "updateMany", "deleteOne", "deleteMany", "dropIndex", "dropIndexes", "sort"];
|
||||
|
||||
export function getMongoCompletionContext(text: string, cursor: number): MongoCompletionContext {
|
||||
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface FormatState {
|
|||
stack: Array<{ char: string; expanded: boolean; chainCall?: boolean }>;
|
||||
}
|
||||
|
||||
const CHAIN_METHODS = new Set(["find", "findOne", "aggregate", "countDocuments", "distinct", "insertOne", "insertMany", "updateOne", "updateMany", "deleteOne", "deleteMany", "getIndexes", "createIndex", "sort", "limit", "skip"]);
|
||||
const CHAIN_METHODS = new Set(["find", "findOne", "aggregate", "countDocuments", "distinct", "insertOne", "insertMany", "updateOne", "updateMany", "deleteOne", "deleteMany", "getIndexes", "createIndex", "dropIndex", "dropIndexes", "sort", "limit", "skip"]);
|
||||
|
||||
export function formatMongoShellText(text: string, settings: Partial<SqlFormatterSettings> = DEFAULT_SQL_FORMATTER_SETTINGS): string {
|
||||
if (!text.trim()) return text;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export type MongoWriteCommand =
|
|||
| { kind: "insert"; collection: string; docsJson: string }
|
||||
| { kind: "update"; collection: string; filter: string; update: string; many: boolean }
|
||||
| { kind: "delete"; collection: string; filter: string; many: boolean }
|
||||
| { kind: "createIndex"; collection: string; keys: string; options?: string };
|
||||
| { kind: "createIndex"; collection: string; keys: string; options?: string }
|
||||
| { kind: "dropIndex"; collection: string; index: string }
|
||||
| { kind: "dropIndexes"; collection: string; indexes?: string };
|
||||
|
||||
export interface MongoAggregateSafetyOptions {
|
||||
allowWrites?: boolean;
|
||||
|
|
@ -220,9 +222,47 @@ export function parseMongoWriteCommand(input: string): MongoWriteCommand | null
|
|||
return { kind: "createIndex", collection: createIndex.collection, keys, ...(options ? { options } : {}) };
|
||||
}
|
||||
|
||||
const dropIndex = parseCollectionMethodTarget(source, "dropIndex");
|
||||
if (dropIndex) {
|
||||
const args = parseMethodArgs(source, dropIndex.methodCallIndex);
|
||||
if (!args) return null;
|
||||
const index = parseMongoDropIndexArgument(args);
|
||||
return index ? { kind: "dropIndex", collection: dropIndex.collection, index } : null;
|
||||
}
|
||||
|
||||
const dropIndexes = parseCollectionMethodTarget(source, "dropIndexes");
|
||||
if (dropIndexes) {
|
||||
const args = parseMethodArgs(source, dropIndexes.methodCallIndex);
|
||||
if (!args) return null;
|
||||
const indexes = parseMongoDropIndexesArgument(args);
|
||||
return indexes !== null ? { kind: "dropIndexes", collection: dropIndexes.collection, ...(indexes ? { indexes } : {}) } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: MongoAggregateSafetyOptions): { allowed: boolean; reason?: string } {
|
||||
if (!options.allowWrites) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete") && isEmptyJsonObject(command.filter)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && mongoDropIndexesRequiresDangerous(command)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB dropIndexes() without a specific single index requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.",
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function mongoAggregateWriteStage(pipelineJson: string): "$out" | "$merge" | null {
|
||||
try {
|
||||
const pipeline = JSON.parse(pipelineJson);
|
||||
|
|
@ -310,6 +350,15 @@ export function mongoCreateIndexToQueryResult(name: string, executionTimeMs: num
|
|||
};
|
||||
}
|
||||
|
||||
export function mongoDroppedIndexesToQueryResult(names: string[], executionTimeMs: number): QueryResult {
|
||||
return {
|
||||
columns: ["name"],
|
||||
rows: names.map((name) => [name]),
|
||||
affected_rows: names.length,
|
||||
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mongoUseToQueryResult(database: string, executionTimeMs: number): QueryResult {
|
||||
return {
|
||||
columns: ["message"],
|
||||
|
|
@ -444,6 +493,26 @@ function convertSingleQuotedStrings(source: string): string {
|
|||
return quote === "'" ? source : result + source.slice(copiedUntil);
|
||||
}
|
||||
|
||||
function parseMongoDropIndexArgument(args: string[]): string | null {
|
||||
if (args.length !== 1 || !args[0]?.trim()) return null;
|
||||
const normalized = normalizeJsonArgument(args[0]);
|
||||
if (!normalized) return null;
|
||||
const parsed = parseNormalizedJson(normalized);
|
||||
if (typeof parsed === "string") return parsed === "*" ? null : normalized;
|
||||
return isNonEmptyRecord(parsed) ? normalized : null;
|
||||
}
|
||||
|
||||
function parseMongoDropIndexesArgument(args: string[]): string | undefined | null {
|
||||
if (args.length !== 1) return null;
|
||||
if (!args[0]?.trim()) return undefined;
|
||||
const normalized = normalizeJsonArgument(args[0]);
|
||||
if (!normalized) return null;
|
||||
const parsed = parseNormalizedJson(normalized);
|
||||
if (typeof parsed === "string") return normalized;
|
||||
if (isNonEmptyRecord(parsed)) return normalized;
|
||||
return Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === "string") ? normalized : null;
|
||||
}
|
||||
|
||||
export function quoteUnquotedObjectKeys(source: string): string {
|
||||
let result = "";
|
||||
let quote: string | null = null;
|
||||
|
|
@ -577,6 +646,31 @@ function escapeRegExp(value: string): string {
|
|||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function parseNormalizedJson(json: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function isEmptyJsonObject(json: string): boolean {
|
||||
const parsed = parseNormalizedJson(json);
|
||||
return isRecord(parsed) && Object.keys(parsed).length === 0;
|
||||
}
|
||||
|
||||
function mongoDropIndexesRequiresDangerous(command: MongoWriteCommand): boolean {
|
||||
if (command.kind !== "dropIndexes") return false;
|
||||
if (!command.indexes) return true;
|
||||
const parsed = parseNormalizedJson(command.indexes);
|
||||
if (parsed === "*") return true;
|
||||
return Array.isArray(parsed) && parsed.length > 1;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1504,6 +1504,10 @@ export async function mongoCreateIndex(connectionId: string, database: string, c
|
|||
return invoke("mongo_create_index", { connectionId, database, collection, keysJson, optionsJson });
|
||||
}
|
||||
|
||||
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> {
|
||||
return invoke("mongo_drop_indexes", { connectionId, database, collection, indexesJson, single });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
|
||||
return documentInsertDocument(connectionId, database, collection, docJson);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuer
|
|||
import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence";
|
||||
import {
|
||||
evaluateMongoAggregateSafety,
|
||||
evaluateMongoWriteSafety,
|
||||
mongoCountToQueryResult,
|
||||
mongoCreateIndexToQueryResult,
|
||||
mongoDocumentsToQueryResult,
|
||||
mongoDroppedIndexesToQueryResult,
|
||||
mongoIndexesToQueryResult,
|
||||
mongoUseToQueryResult,
|
||||
mongoVersionToQueryResult,
|
||||
|
|
@ -1792,6 +1794,10 @@ export const useQueryStore = defineStore("query", () => {
|
|||
|
||||
const mongoWrite = conn?.db_type === "mongodb" ? parseMongoWriteCommand(sql) : null;
|
||||
if (mongoWrite) {
|
||||
if (options?.mongoSafety) {
|
||||
const safety = evaluateMongoWriteSafety(mongoWrite, options.mongoSafety);
|
||||
if (!safety.allowed) throw new Error(safety.reason);
|
||||
}
|
||||
await connStore.ensureConnected(tab.connectionId);
|
||||
console.info("[DBX][executeTabSql:mongo-write:start]", {
|
||||
traceId,
|
||||
|
|
@ -1828,6 +1834,29 @@ export const useQueryStore = defineStore("query", () => {
|
|||
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
|
||||
}
|
||||
return;
|
||||
} else if (mongoWrite.kind === "dropIndex" || mongoWrite.kind === "dropIndexes") {
|
||||
const result = await api.mongoDropIndexes(tab.connectionId, tab.database, mongoWrite.collection, mongoWrite.kind === "dropIndex" ? mongoWrite.index : mongoWrite.indexes, mongoWrite.kind === "dropIndex");
|
||||
console.info("[DBX][executeTabSql:mongo-write:done]", {
|
||||
traceId,
|
||||
droppedNames: result.dropped_names,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
if (current?.executionId === executionId) {
|
||||
current.results = undefined;
|
||||
current.activeResultIndex = undefined;
|
||||
current.result = markQueryResultRowsRaw(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - startedAt));
|
||||
touchResult(current);
|
||||
current.queryAnalysis = undefined;
|
||||
current.querySourceColumns = undefined;
|
||||
current.queryEditabilityReason = undefined;
|
||||
current.mongoEditTarget = undefined;
|
||||
current.tableMeta = undefined;
|
||||
current.resultBaseSql = options?.resultBaseSql ?? sql;
|
||||
current.resultSortedSql = options?.resultSortedSql;
|
||||
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const result = await api.mongoDeleteDocuments(tab.connectionId, tab.database, mongoWrite.collection, mongoWrite.filter, mongoWrite.many);
|
||||
affectedRows = result.affected_rows;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use super::with_connection_timeout;
|
|||
use crate::types::IndexInfo;
|
||||
use futures::TryStreamExt;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MongoDocumentResult {
|
||||
|
|
@ -17,6 +17,12 @@ pub struct MongoDocumentResult {
|
|||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MongoDropIndexesResult {
|
||||
pub dropped_names: Vec<String>,
|
||||
pub affected_rows: u64,
|
||||
}
|
||||
|
||||
pub async fn connect(url: &str, timeout: Duration, idle_timeout: Duration) -> Result<Client, String> {
|
||||
let url = normalize_mongo_uri_direct_connection(url);
|
||||
let is_multi_host = is_multi_host_mongo_uri(&url);
|
||||
|
|
@ -367,6 +373,102 @@ pub async fn create_index(
|
|||
Ok(result.index_name)
|
||||
}
|
||||
|
||||
pub async fn drop_indexes(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
indexes_json: Option<&str>,
|
||||
single: bool,
|
||||
) -> Result<MongoDropIndexesResult, String> {
|
||||
let database = database.trim();
|
||||
let collection = collection.trim();
|
||||
if database.is_empty() {
|
||||
return Err("Database name is required".to_string());
|
||||
}
|
||||
if collection.is_empty() {
|
||||
return Err("Collection name is required".to_string());
|
||||
}
|
||||
|
||||
let index = parse_drop_indexes_value(indexes_json, single)?;
|
||||
let before = list_indexes(client, database, collection).await?;
|
||||
client
|
||||
.database(database)
|
||||
.run_command(doc! { "dropIndexes": collection, "index": index })
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let after = list_indexes(client, database, collection).await?;
|
||||
let dropped_names = diff_dropped_index_names(&before, &after);
|
||||
Ok(MongoDropIndexesResult { affected_rows: dropped_names.len() as u64, dropped_names })
|
||||
}
|
||||
|
||||
fn diff_dropped_index_names(before: &[IndexInfo], after: &[IndexInfo]) -> Vec<String> {
|
||||
let remaining = after.iter().map(|index| index.name.as_str()).collect::<HashSet<_>>();
|
||||
before.iter().filter(|index| !remaining.contains(index.name.as_str())).map(|index| index.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn parse_drop_indexes_value(indexes_json: Option<&str>, single: bool) -> Result<Bson, String> {
|
||||
match indexes_json.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(json) => parse_drop_indexes_json(json, single),
|
||||
None if single => Err("dropIndex requires a string index name or JSON document".to_string()),
|
||||
None => Ok(Bson::String("*".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_drop_indexes_json(json: &str, single: bool) -> Result<Bson, String> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("Invalid index JSON: {e}"))?;
|
||||
if single {
|
||||
validate_single_drop_index_value(&value)?;
|
||||
} else {
|
||||
validate_multi_drop_indexes_value(&value)?;
|
||||
}
|
||||
Ok(json_value_to_bson(&value))
|
||||
}
|
||||
|
||||
fn validate_single_drop_index_value(value: &serde_json::Value) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::String(name) => {
|
||||
if name.trim().is_empty() {
|
||||
Err("Index name is required".to_string())
|
||||
} else if name == "*" {
|
||||
Err(r#"dropIndex does not accept "*"; use dropIndexes() or dropIndexes("*") instead"#.to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
|
||||
serde_json::Value::Object(_) => Ok(()),
|
||||
serde_json::Value::Array(_) => {
|
||||
Err("dropIndex only accepts a string index name or JSON document; arrays are not supported".to_string())
|
||||
}
|
||||
_ => Err("dropIndex only accepts a string index name or JSON document".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_multi_drop_indexes_value(value: &serde_json::Value) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::String(name) => {
|
||||
if name.trim().is_empty() {
|
||||
Err("Index name is required".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
|
||||
serde_json::Value::Object(_) => Ok(()),
|
||||
serde_json::Value::Array(items) if items.is_empty() => {
|
||||
Err("dropIndexes only accepts non-empty string arrays".to_string())
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
if items.iter().all(|item| matches!(item, serde_json::Value::String(name) if !name.trim().is_empty())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("dropIndexes only accepts arrays of string index names".to_string())
|
||||
}
|
||||
}
|
||||
_ => Err("dropIndexes only accepts a string index name, JSON document, or string array".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert_document(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
|
|
@ -991,6 +1093,95 @@ mod tests {
|
|||
assert!(index.is_primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_drop_indexes_value_validates_drop_index_arguments() {
|
||||
assert!(matches!(
|
||||
parse_drop_indexes_value(Some(r#""users_email_unique""#), true),
|
||||
Ok(Bson::String(name)) if name == "users_email_unique"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_drop_indexes_value(Some(r#"{"email":1}"#), true),
|
||||
Ok(Bson::Document(doc)) if doc.get_i64("email").ok() == Some(1)
|
||||
));
|
||||
|
||||
let wildcard = parse_drop_indexes_value(Some(r#""*""#), true).unwrap_err();
|
||||
assert!(wildcard.contains("dropIndex does not accept"));
|
||||
|
||||
let array = parse_drop_indexes_value(Some(r#"["a_1"]"#), true).unwrap_err();
|
||||
assert!(array.contains("arrays are not supported"));
|
||||
|
||||
let empty = parse_drop_indexes_value(None, true).unwrap_err();
|
||||
assert!(empty.contains("dropIndex requires"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_drop_indexes_value_validates_drop_indexes_arguments() {
|
||||
assert!(matches!(
|
||||
parse_drop_indexes_value(None, false),
|
||||
Ok(Bson::String(name)) if name == "*"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_drop_indexes_value(Some(r#""*""#), false),
|
||||
Ok(Bson::String(name)) if name == "*"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_drop_indexes_value(Some(r#""users_email_unique""#), false),
|
||||
Ok(Bson::String(name)) if name == "users_email_unique"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_drop_indexes_value(Some(r#"{"email":1}"#), false),
|
||||
Ok(Bson::Document(doc)) if doc.get_i64("email").ok() == Some(1)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_drop_indexes_value(Some(r#"["a_1","b_1"]"#), false),
|
||||
Ok(Bson::Array(values))
|
||||
if values
|
||||
== vec![Bson::String("a_1".to_string()), Bson::String("b_1".to_string())]
|
||||
));
|
||||
|
||||
let invalid_array = parse_drop_indexes_value(Some(r#"[{"a":1}]"#), false).unwrap_err();
|
||||
assert!(invalid_array.contains("arrays of string index names"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_dropped_index_names_reports_removed_indexes() {
|
||||
let before = vec![
|
||||
IndexInfo {
|
||||
name: "_id_".to_string(),
|
||||
columns: vec!["_id".to_string()],
|
||||
is_unique: true,
|
||||
is_primary: true,
|
||||
filter: None,
|
||||
index_type: Some("_id: 1".to_string()),
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
},
|
||||
IndexInfo {
|
||||
name: "users_email_unique".to_string(),
|
||||
columns: vec!["email".to_string()],
|
||||
is_unique: true,
|
||||
is_primary: false,
|
||||
filter: None,
|
||||
index_type: Some("email: 1".to_string()),
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
},
|
||||
IndexInfo {
|
||||
name: "users_status_idx".to_string(),
|
||||
columns: vec!["status".to_string()],
|
||||
is_unique: false,
|
||||
is_primary: false,
|
||||
filter: None,
|
||||
index_type: Some("status: 1".to_string()),
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
},
|
||||
];
|
||||
let after = vec![before[0].clone(), before[2].clone()];
|
||||
|
||||
assert_eq!(diff_dropped_index_names(&before, &after), vec!["users_email_unique".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_object_to_document_parses_extended_json_date() {
|
||||
let value = serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::connection::{AppState, PoolKind};
|
||||
use crate::db::mongo_driver::{self, MongoDocumentResult};
|
||||
use crate::db::mongo_driver::{self, MongoDocumentResult, MongoDropIndexesResult};
|
||||
use crate::document_ops::CollectionInfo;
|
||||
|
||||
async fn ensure_document_pool(state: &AppState, connection_id: &str) -> Result<(), String> {
|
||||
|
|
@ -186,6 +186,25 @@ pub async fn mongo_create_index_core(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn mongo_drop_indexes_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
indexes_json: Option<&str>,
|
||||
single: bool,
|
||||
) -> Result<MongoDropIndexesResult, 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::drop_indexes(client, database, collection, indexes_json, single).await
|
||||
}
|
||||
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support dropIndex/dropIndexes".to_string()),
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mongo_insert_document_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
|
|
|||
|
|
@ -455,6 +455,7 @@ async fn main() {
|
|||
.route("/mongo/server-version", post(routes::mongo::server_version))
|
||||
.route("/mongo/aggregate-documents", post(routes::mongo::aggregate_documents))
|
||||
.route("/mongo/create-index", post(routes::mongo::create_index))
|
||||
.route("/mongo/drop-indexes", post(routes::mongo::drop_indexes))
|
||||
.route("/mongo/insert-document", post(routes::mongo::insert_document))
|
||||
.route("/mongo/insert-documents", post(routes::mongo::insert_documents))
|
||||
.route("/mongo/update-document", post(routes::mongo::update_document))
|
||||
|
|
|
|||
|
|
@ -107,6 +107,16 @@ pub struct MongoCreateIndexRequest {
|
|||
pub options_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoDropIndexesRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub collection: String,
|
||||
pub indexes_json: Option<String>,
|
||||
pub single: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoInsertRequest {
|
||||
|
|
@ -293,6 +303,24 @@ pub async fn create_index(
|
|||
Ok(Json(serde_json::json!({ "name": name })))
|
||||
}
|
||||
|
||||
pub async fn drop_indexes(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoDropIndexesRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "Drop indexes").await?;
|
||||
let result = dbx_core::mongo_ops::mongo_drop_indexes_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.collection,
|
||||
req.indexes_json.as_deref(),
|
||||
req.single,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn insert_document(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoInsertRequest>,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { strict as assert } from "node:assert";
|
|||
import { test } from "vitest";
|
||||
import {
|
||||
evaluateMongoAggregateSafety,
|
||||
evaluateMongoWriteSafety,
|
||||
mongoAggregateWriteStage,
|
||||
mongoCountToQueryResult,
|
||||
mongoDocumentsToQueryResult,
|
||||
|
|
@ -114,6 +115,55 @@ test("parseMongoWriteCommand parses createIndex with optional options", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("parseMongoWriteCommand parses dropIndex and dropIndexes variants", () => {
|
||||
assert.deepEqual(parseMongoWriteCommand('db.users.dropIndex("users_email_unique")'), {
|
||||
kind: "dropIndex",
|
||||
collection: "users",
|
||||
index: '"users_email_unique"',
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand("db.users.dropIndex({email: 1})"), {
|
||||
kind: "dropIndex",
|
||||
collection: "users",
|
||||
index: '{"email": 1}',
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand("db.users.dropIndexes()"), {
|
||||
kind: "dropIndexes",
|
||||
collection: "users",
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand("db.users.dropIndexes({email: 1})"), {
|
||||
kind: "dropIndexes",
|
||||
collection: "users",
|
||||
indexes: '{"email": 1}',
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand('db.users.dropIndexes("*")'), {
|
||||
kind: "dropIndexes",
|
||||
collection: "users",
|
||||
indexes: '"*"',
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand('db.users.dropIndexes(["a_1", "b_1"])'), {
|
||||
kind: "dropIndexes",
|
||||
collection: "users",
|
||||
indexes: '["a_1", "b_1"]',
|
||||
});
|
||||
});
|
||||
|
||||
test("parseMongoWriteCommand rejects invalid dropIndex/dropIndexes variants", () => {
|
||||
assert.equal(parseMongoWriteCommand("db.users.dropIndex()"), null);
|
||||
assert.equal(parseMongoWriteCommand('db.users.dropIndex("*")'), null);
|
||||
assert.equal(parseMongoWriteCommand('db.users.dropIndex(["a_1"])'), null);
|
||||
assert.equal(parseMongoWriteCommand('db.users.dropIndexes([{"a":1}])'), null);
|
||||
});
|
||||
|
||||
test("evaluateMongoWriteSafety blocks dangerous dropIndexes shapes unless enabled", () => {
|
||||
const dropAll = parseMongoWriteCommand("db.users.dropIndexes()");
|
||||
assert.ok(dropAll);
|
||||
assert.match(evaluateMongoWriteSafety(dropAll, { allowWrites: true }).reason || "", /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
|
||||
const dropOne = parseMongoWriteCommand('db.users.dropIndexes("users_email_unique")');
|
||||
assert.ok(dropOne);
|
||||
assert.equal(evaluateMongoWriteSafety(dropOne, { allowWrites: true }).allowed, true);
|
||||
});
|
||||
|
||||
test("parseMongoCountDocumentsCommand parses db collection countDocuments", () => {
|
||||
assert.deepEqual(parseMongoCountDocumentsCommand("db.products.countDocuments({})"), {
|
||||
collection: "products",
|
||||
|
|
|
|||
|
|
@ -1999,6 +1999,111 @@ test("mongo createIndex execution uses the dedicated create-index endpoint", asy
|
|||
}
|
||||
});
|
||||
|
||||
test("mongo dropIndex execution uses the dedicated drop-indexes endpoint", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let dropIndexesBody: any;
|
||||
|
||||
connectionStore.addEphemeralConnection({
|
||||
...conn("mongo-1"),
|
||||
db_type: "mongodb",
|
||||
port: 27017,
|
||||
});
|
||||
|
||||
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/mongo/drop-indexes") {
|
||||
dropIndexesBody = JSON.parse(String(init?.body ?? "{}"));
|
||||
return new Response(JSON.stringify({ dropped_names: ["users_email_unique"], affected_rows: 1 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
});
|
||||
|
||||
try {
|
||||
const tabId = store.createTab("mongo-1", "accounting", "Query", "query", "");
|
||||
await store.executeTabSql(tabId, 'db.users.dropIndex("users_email_unique")');
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
|
||||
assert.deepEqual(dropIndexesBody, {
|
||||
connectionId: "mongo-1",
|
||||
database: "accounting",
|
||||
collection: "users",
|
||||
indexesJson: '"users_email_unique"',
|
||||
single: true,
|
||||
});
|
||||
assert.deepEqual(tab?.result?.columns, ["name"]);
|
||||
assert.deepEqual(tab?.result?.rows, [["users_email_unique"]]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("mongo dropIndexes execution returns dropped index names", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let dropIndexesBody: any;
|
||||
|
||||
connectionStore.addEphemeralConnection({
|
||||
...conn("mongo-1"),
|
||||
db_type: "mongodb",
|
||||
port: 27017,
|
||||
});
|
||||
|
||||
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/mongo/drop-indexes") {
|
||||
dropIndexesBody = JSON.parse(String(init?.body ?? "{}"));
|
||||
return new Response(JSON.stringify({ dropped_names: ["a_1", "b_1"], affected_rows: 2 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
});
|
||||
|
||||
try {
|
||||
const tabId = store.createTab("mongo-1", "accounting", "Query", "query", "");
|
||||
await store.executeTabSql(tabId, "db.users.dropIndexes()");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
|
||||
assert.deepEqual(dropIndexesBody, {
|
||||
connectionId: "mongo-1",
|
||||
database: "accounting",
|
||||
collection: "users",
|
||||
single: false,
|
||||
});
|
||||
assert.deepEqual(tab?.result?.columns, ["name"]);
|
||||
assert.deepEqual(tab?.result?.rows, [["a_1"], ["b_1"]]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("table data export fetches every filtered page", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
|
|
|
|||
|
|
@ -663,9 +663,18 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
|
|||
row_count: 1,
|
||||
};
|
||||
}
|
||||
if (write.kind === "dropIndex" || write.kind === "dropIndexes") {
|
||||
return {
|
||||
columns: ["name"],
|
||||
rows: (result.droppedNames ?? []).map((name) => ({ name })),
|
||||
row_count: result.affectedRows,
|
||||
};
|
||||
}
|
||||
return { columns: [], rows: [], row_count: result.affectedRows };
|
||||
}
|
||||
throw new Error("Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.getIndexes(), db.projects.createIndex({...}), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})");
|
||||
throw new Error(
|
||||
"Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.getIndexes(), db.projects.createIndex({...}), db.projects.dropIndex(\"name\"), db.projects.dropIndexes(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})",
|
||||
);
|
||||
}
|
||||
if (isDirectQueryType(config.db_type)) {
|
||||
return query(config, sql, undefined, options);
|
||||
|
|
@ -883,7 +892,7 @@ async function mongoServerVersion(config: ConnectionConfig): Promise<string> {
|
|||
async function executeMongoWrite(
|
||||
config: ConnectionConfig,
|
||||
command: MongoWriteCommand,
|
||||
): Promise<{ affectedRows: number; indexName?: string }> {
|
||||
): Promise<{ affectedRows: number; indexName?: string; droppedNames?: string[] }> {
|
||||
if (command.kind === "insert") {
|
||||
const result = await bridgeDataRequest<{ affected_rows: number }>("/data/mongo/insert-documents", {
|
||||
connection_name: config.name,
|
||||
|
|
@ -914,6 +923,16 @@ async function executeMongoWrite(
|
|||
});
|
||||
return { affectedRows: 1, indexName: result.name };
|
||||
}
|
||||
if (command.kind === "dropIndex" || command.kind === "dropIndexes") {
|
||||
const result = await bridgeDataRequest<{ dropped_names: string[]; affected_rows: number }>("/data/mongo/drop-indexes", {
|
||||
connection_name: config.name,
|
||||
database: config.database || "",
|
||||
collection: command.collection,
|
||||
indexes_json: command.kind === "dropIndex" ? command.index : command.indexes,
|
||||
single: command.kind === "dropIndex",
|
||||
});
|
||||
return { affectedRows: result.affected_rows, droppedNames: result.dropped_names };
|
||||
}
|
||||
const result = await bridgeDataRequest<{ affected_rows: number }>("/data/mongo/delete-documents", {
|
||||
connection_name: config.name,
|
||||
database: config.database || "",
|
||||
|
|
@ -1008,7 +1027,9 @@ export type MongoWriteCommand =
|
|||
| { kind: "insert"; collection: string; docsJson: string }
|
||||
| { kind: "update"; collection: string; filter: string; update: string; many: boolean }
|
||||
| { kind: "delete"; collection: string; filter: string; many: boolean }
|
||||
| { kind: "createIndex"; collection: string; keys: string; options?: string };
|
||||
| { kind: "createIndex"; collection: string; keys: string; options?: string }
|
||||
| { kind: "dropIndex"; collection: string; index: string }
|
||||
| { kind: "dropIndexes"; collection: string; indexes?: string };
|
||||
|
||||
export function parseMongoFindCommand(input: string): MongoFindCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
|
|
@ -1150,6 +1171,22 @@ export function parseMongoWriteCommand(input: string): MongoWriteCommand | null
|
|||
return { kind: "createIndex", collection: createIndex.collection, keys, ...(options ? { options } : {}) };
|
||||
}
|
||||
|
||||
const dropIndex = parseCollectionMethodTarget(source, "dropIndex");
|
||||
if (dropIndex) {
|
||||
const args = parseMethodArgs(source, dropIndex.methodCallIndex);
|
||||
if (!args) return null;
|
||||
const index = parseMongoDropIndexArgument(args);
|
||||
return index ? { kind: "dropIndex", collection: dropIndex.collection, index } : null;
|
||||
}
|
||||
|
||||
const dropIndexes = parseCollectionMethodTarget(source, "dropIndexes");
|
||||
if (dropIndexes) {
|
||||
const args = parseMethodArgs(source, dropIndexes.methodCallIndex);
|
||||
if (!args) return null;
|
||||
const indexes = parseMongoDropIndexesArgument(args);
|
||||
return indexes !== null ? { kind: "dropIndexes", collection: dropIndexes.collection, ...(indexes ? { indexes } : {}) } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1166,6 +1203,12 @@ export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: {
|
|||
reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
};
|
||||
}
|
||||
if (!options.allowDangerous && mongoDropIndexesRequiresDangerous(command)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MongoDB dropIndexes() without a specific single index requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.",
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
|
|
@ -1240,6 +1283,26 @@ function normalizeJsonArgument(arg: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function parseMongoDropIndexArgument(args: string[]): string | null {
|
||||
if (args.length !== 1 || !args[0]?.trim()) return null;
|
||||
const normalized = normalizeJsonArgument(args[0]);
|
||||
if (!normalized) return null;
|
||||
const parsed = parseNormalizedJson(normalized);
|
||||
if (typeof parsed === "string") return parsed === "*" ? null : normalized;
|
||||
return isNonEmptyRecord(parsed) ? normalized : null;
|
||||
}
|
||||
|
||||
function parseMongoDropIndexesArgument(args: string[]): string | undefined | null {
|
||||
if (args.length !== 1) return null;
|
||||
if (!args[0]?.trim()) return undefined;
|
||||
const normalized = normalizeJsonArgument(args[0]);
|
||||
if (!normalized) return null;
|
||||
const parsed = parseNormalizedJson(normalized);
|
||||
if (typeof parsed === "string") return normalized;
|
||||
if (isNonEmptyRecord(parsed)) return normalized;
|
||||
return Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === "string") ? normalized : null;
|
||||
}
|
||||
|
||||
function convertSingleQuotedStrings(source: string): string {
|
||||
let result = "";
|
||||
let copiedUntil = 0;
|
||||
|
|
@ -1332,6 +1395,18 @@ function shouldQuoteObjectKey(source: string, index: number): boolean {
|
|||
return source[after] === ":";
|
||||
}
|
||||
|
||||
function parseNormalizedJson(json: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
function isEmptyJsonObject(json: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
|
|
@ -1341,6 +1416,14 @@ function isEmptyJsonObject(json: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function mongoDropIndexesRequiresDangerous(command: MongoWriteCommand): boolean {
|
||||
if (command.kind !== "dropIndexes") return false;
|
||||
if (!command.indexes) return true;
|
||||
const parsed = parseNormalizedJson(command.indexes);
|
||||
if (parsed === "*") return true;
|
||||
return Array.isArray(parsed) && parsed.length > 1;
|
||||
}
|
||||
|
||||
function splitTopLevel(source: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
|
|
|
|||
|
|
@ -214,9 +214,14 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option
|
|||
if (write.kind === "createIndex") {
|
||||
return { columns: ["name"], rows: [{ name: result.indexName ?? "" }], row_count: 1 };
|
||||
}
|
||||
if (write.kind === "dropIndex" || write.kind === "dropIndexes") {
|
||||
return { columns: ["name"], rows: (result.droppedNames ?? []).map((name) => ({ name })), row_count: result.affectedRows };
|
||||
}
|
||||
return { columns: [], rows: [], row_count: result.affectedRows };
|
||||
}
|
||||
throw new Error("Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.getIndexes(), db.projects.createIndex({...}), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})");
|
||||
throw new Error(
|
||||
"Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.version(), db.projects.countDocuments({}), db.projects.getIndexes(), db.projects.createIndex({...}), db.projects.dropIndex(\"name\"), db.projects.dropIndexes(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})",
|
||||
);
|
||||
}
|
||||
const res = await apiFetch("/api/query/execute", {
|
||||
method: "POST",
|
||||
|
|
@ -258,7 +263,7 @@ export async function executeRedisCommand(config: ConnectionConfig, db: number,
|
|||
async function executeMongoWrite(
|
||||
config: ConnectionConfig,
|
||||
command: MongoWriteCommand,
|
||||
): Promise<{ affectedRows: number; indexName?: string }> {
|
||||
): Promise<{ affectedRows: number; indexName?: string; droppedNames?: string[] }> {
|
||||
if (command.kind === "insert") {
|
||||
const res = await apiFetch("/api/mongo/insert-documents", {
|
||||
method: "POST",
|
||||
|
|
@ -301,6 +306,20 @@ async function executeMongoWrite(
|
|||
const result = (await res.json()) as { name: string };
|
||||
return { affectedRows: 1, indexName: result.name };
|
||||
}
|
||||
if (command.kind === "dropIndex" || command.kind === "dropIndexes") {
|
||||
const res = await apiFetch("/api/mongo/drop-indexes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
connectionId: config.id,
|
||||
database: config.database || "",
|
||||
collection: command.collection,
|
||||
indexesJson: command.kind === "dropIndex" ? command.index : command.indexes,
|
||||
single: command.kind === "dropIndex",
|
||||
}),
|
||||
});
|
||||
const result = (await res.json()) as { dropped_names: string[]; affected_rows: number };
|
||||
return { affectedRows: result.affected_rows, droppedNames: result.dropped_names };
|
||||
}
|
||||
const res = await apiFetch("/api/mongo/delete-documents", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -141,6 +141,32 @@ test("parseMongoWriteCommand accepts supported write commands", () => {
|
|||
keys: '{"email":1}',
|
||||
options: '{"unique":true,"name":"projects_email_unique"}',
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndex("projects_email_unique")'), {
|
||||
kind: "dropIndex",
|
||||
collection: "projects",
|
||||
index: '"projects_email_unique"',
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand("db.projects.dropIndexes()"), {
|
||||
kind: "dropIndexes",
|
||||
collection: "projects",
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndexes({"email":1})'), {
|
||||
kind: "dropIndexes",
|
||||
collection: "projects",
|
||||
indexes: '{"email":1}',
|
||||
});
|
||||
assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndexes(["a_1","b_1"])'), {
|
||||
kind: "dropIndexes",
|
||||
collection: "projects",
|
||||
indexes: '["a_1","b_1"]',
|
||||
});
|
||||
});
|
||||
|
||||
test("parseMongoWriteCommand rejects invalid dropIndex and dropIndexes commands", () => {
|
||||
assert.equal(parseMongoWriteCommand("db.projects.dropIndex()"), null);
|
||||
assert.equal(parseMongoWriteCommand('db.projects.dropIndex("*")'), null);
|
||||
assert.equal(parseMongoWriteCommand('db.projects.dropIndex(["a_1"])'), null);
|
||||
assert.equal(parseMongoWriteCommand('db.projects.dropIndexes([{"email":1}])'), null);
|
||||
});
|
||||
|
||||
test("mongodb executeQuery blocks writes when writes are explicitly disabled", async () => {
|
||||
|
|
@ -193,6 +219,60 @@ test("mongodb executeQuery treats createIndex as a write when writes are explici
|
|||
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
|
||||
});
|
||||
|
||||
test("mongodb executeQuery treats dropIndex as a write when writes are explicitly disabled", async () => {
|
||||
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
|
||||
process.env.DBX_MCP_ALLOW_WRITES = "0";
|
||||
await assert.rejects(
|
||||
executeQuery(
|
||||
{
|
||||
id: "mongo",
|
||||
name: "mongo",
|
||||
db_type: "mongodb",
|
||||
host: "127.0.0.1",
|
||||
port: 27017,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "app",
|
||||
ssh_enabled: false,
|
||||
ssl: false,
|
||||
},
|
||||
'db.projects.dropIndex("projects_email_unique")',
|
||||
),
|
||||
/read-only/i,
|
||||
);
|
||||
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
|
||||
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
|
||||
});
|
||||
|
||||
test("mongodb executeQuery blocks dangerous dropIndexes shapes until dangerous SQL is enabled", async () => {
|
||||
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
|
||||
const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
||||
process.env.DBX_MCP_ALLOW_WRITES = "1";
|
||||
delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
||||
|
||||
const config = {
|
||||
id: "mongo",
|
||||
name: "mongo",
|
||||
db_type: "mongodb",
|
||||
host: "127.0.0.1",
|
||||
port: 27017,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "app",
|
||||
ssh_enabled: false,
|
||||
ssl: false,
|
||||
} as const;
|
||||
|
||||
await assert.rejects(executeQuery(config, "db.projects.dropIndexes()"), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
await assert.rejects(executeQuery(config, 'db.projects.dropIndexes("*")'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
await assert.rejects(executeQuery(config, 'db.projects.dropIndexes(["a_1","b_1"])'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
|
||||
|
||||
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
|
||||
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
|
||||
if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
|
||||
else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous;
|
||||
});
|
||||
|
||||
test("mongoDocumentsToQueryResult turns documents into rows", () => {
|
||||
assert.deepEqual(
|
||||
mongoDocumentsToQueryResult(
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ struct MongoCreateIndexRequest {
|
|||
options_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MongoDropIndexesRequest {
|
||||
connection_name: String,
|
||||
database: Option<String>,
|
||||
collection: String,
|
||||
indexes_json: Option<String>,
|
||||
single: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MongoInsertDocumentsRequest {
|
||||
connection_name: String,
|
||||
|
|
@ -182,6 +191,8 @@ pub fn start(app_handle: AppHandle, state: Arc<AppState>, data_dir: PathBuf) {
|
|||
handle_mongo_aggregate_documents_data(&st, body, &mut stream).await;
|
||||
} else if first_line.starts_with("POST /data/mongo/create-index") {
|
||||
handle_mongo_create_index_data(&st, body, &mut stream).await;
|
||||
} else if first_line.starts_with("POST /data/mongo/drop-indexes") {
|
||||
handle_mongo_drop_indexes_data(&st, body, &mut stream).await;
|
||||
} else if first_line.starts_with("POST /data/mongo/insert-documents") {
|
||||
handle_mongo_insert_documents_data(&st, body, &mut stream).await;
|
||||
} else if first_line.starts_with("POST /data/mongo/update-documents") {
|
||||
|
|
@ -554,6 +565,38 @@ async fn handle_mongo_create_index_data(state: &Arc<AppState>, body: &str, strea
|
|||
}
|
||||
}
|
||||
|
||||
async fn handle_mongo_drop_indexes_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
|
||||
let req: MongoDropIndexesRequest = match serde_json::from_str(body) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
respond_error(stream, "400 Bad Request", "Invalid JSON").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some((pool_key, database, connection_id)) =
|
||||
resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = ensure_connection_writable(state, &connection_id, "Drop indexes").await {
|
||||
respond_error(stream, "403 Forbidden", &e).await;
|
||||
return;
|
||||
}
|
||||
match dbx_core::mongo_ops::mongo_drop_indexes_core(
|
||||
state,
|
||||
&pool_key,
|
||||
&database,
|
||||
&req.collection,
|
||||
req.indexes_json.as_deref(),
|
||||
req.single,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => respond_json(stream, &result).await,
|
||||
Err(e) => respond_error(stream, "500 Internal Server Error", &e).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_mongo_insert_documents_data(state: &Arc<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
|
||||
let req: MongoInsertDocumentsRequest = match serde_json::from_str(body) {
|
||||
Ok(r) => r,
|
||||
|
|
|
|||
|
|
@ -160,6 +160,27 @@ pub async fn mongo_create_index(
|
|||
Ok(serde_json::json!({ "name": name }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_drop_indexes(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
collection: String,
|
||||
indexes_json: Option<String>,
|
||||
single: bool,
|
||||
) -> Result<dbx_core::db::mongo_driver::MongoDropIndexesResult, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Drop indexes").await?;
|
||||
dbx_core::mongo_ops::mongo_drop_indexes_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
&collection,
|
||||
indexes_json.as_deref(),
|
||||
single,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_insert_document(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
|
|
|||
|
|
@ -854,6 +854,7 @@ pub fn run() {
|
|||
commands::mongo_cmd::mongo_server_version,
|
||||
commands::mongo_cmd::mongo_aggregate_documents,
|
||||
commands::mongo_cmd::mongo_create_index,
|
||||
commands::mongo_cmd::mongo_drop_indexes,
|
||||
commands::document_cmd::document_insert_document,
|
||||
commands::mongo_cmd::mongo_insert_document,
|
||||
commands::mongo_cmd::mongo_insert_documents,
|
||||
|
|
|
|||
Loading…
Reference in New Issue