From 93eeeaa9c651a33d188b899fb115cc6297dc5c4d Mon Sep 17 00:00:00 2001 From: onenewcode Date: Wed, 1 Jul 2026 17:53:09 +0800 Subject: [PATCH] feat(mongo): add dropIndex and dropIndexes support across full stack --- .../components/editor/DangerConfirmDialog.vue | 3 + apps/desktop/src/components/grid/DataGrid.vue | 119 ++++++++++- .../src/components/sidebar/TreeItem.vue | 200 +++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 7 + apps/desktop/src/i18n/locales/es.ts | 7 + apps/desktop/src/i18n/locales/it.ts | 7 + apps/desktop/src/i18n/locales/ja.ts | 7 + apps/desktop/src/i18n/locales/pt-BR.ts | 7 + apps/desktop/src/i18n/locales/zh-CN.ts | 7 + apps/desktop/src/i18n/locales/zh-TW.ts | 7 + apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/http.ts | 4 + apps/desktop/src/lib/mongoCompletion.ts | 4 +- apps/desktop/src/lib/mongoFormatter.ts | 2 +- apps/desktop/src/lib/mongoShellCommand.ts | 96 ++++++++- apps/desktop/src/lib/tauri.ts | 4 + apps/desktop/src/stores/queryStore.ts | 29 +++ crates/dbx-core/src/db/mongo_driver.rs | 193 ++++++++++++++++- crates/dbx-core/src/mongo_ops.rs | 21 +- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/mongo.rs | 28 +++ packages/app-tests/mongoShellCommand.test.ts | 50 +++++ packages/app-tests/queryStore.test.ts | 105 +++++++++ packages/node-core/src/database.ts | 89 +++++++- packages/node-core/src/web-backend.ts | 23 +- packages/node-core/tests/mongo-query.test.ts | 80 +++++++ src-tauri/src/commands/mcp_bridge.rs | 43 ++++ src-tauri/src/commands/mongo_cmd.rs | 21 ++ src-tauri/src/lib.rs | 1 + 29 files changed, 1142 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/components/editor/DangerConfirmDialog.vue b/apps/desktop/src/components/editor/DangerConfirmDialog.vue index 094199ef8..cab88ae3c 100644 --- a/apps/desktop/src/components/editor/DangerConfirmDialog.vue +++ b/apps/desktop/src/components/editor/DangerConfirmDialog.vue @@ -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() {

{{ message || t("dangerDialog.message") }}

+

{{ detailsText }}

+
+ +
-
{{ index.name }}
-
- PK - UNIQUE - {{ index.index_type }} -
-
- {{ index.columns.join(", ") }} +
+
+
{{ index.name }}
+
+ PK + UNIQUE + {{ index.index_type }} +
+
+ {{ index.columns.join(", ") }} +
+
+
@@ -10143,6 +10225,27 @@ const gridContextMenuItems = computed(() => { :confirm-label="pendingDeleteRowIds.length > 1 ? t('grid.deleteRows', { count: pendingDeleteRowIds.length }) : t('grid.deleteRow')" @confirm="confirmDeleteRow" /> + + diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index d62d63055..5cfa10fba 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -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 { @@ -1792,6 +1822,9 @@ async function dropSqlForTreeNode(node: TreeNode): Promise { } 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 { 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(); + 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, 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): 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): 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) { + 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" /> + + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 95b40e5af..987f4ee22 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 72a1bd5f6..3a8062554 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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', diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 341040abb..59ecf0cec 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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', diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 19c8a5e5e..d2b4fb656 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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}」をトランケートしました", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index faec4df69..922c1a81e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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', diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 94bac0984..b50845869 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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}」已截断", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 23272542d..cc9545847 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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}」已截斷", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index ec69252f3..85175560a 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -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"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 4106b9dd7..d752aed47 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -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 { return documentInsertDocument(connectionId, database, collection, docJson); } diff --git a/apps/desktop/src/lib/mongoCompletion.ts b/apps/desktop/src/lib/mongoCompletion.ts index f0ae3521e..ddea7d704 100644 --- a/apps/desktop/src/lib/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongoCompletion.ts @@ -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)); diff --git a/apps/desktop/src/lib/mongoFormatter.ts b/apps/desktop/src/lib/mongoFormatter.ts index bff7adf29..708830495 100644 --- a/apps/desktop/src/lib/mongoFormatter.ts +++ b/apps/desktop/src/lib/mongoFormatter.ts @@ -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 = DEFAULT_SQL_FORMATTER_SETTINGS): string { if (!text.trim()) return text; diff --git a/apps/desktop/src/lib/mongoShellCommand.ts b/apps/desktop/src/lib/mongoShellCommand.ts index 7adac1624..9789e3d00 100644 --- a/apps/desktop/src/lib/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongoShellCommand.ts @@ -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 { + 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 { return !!value && typeof value === "object" && !Array.isArray(value); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 751e7d134..95700300f 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -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 { return documentInsertDocument(connectionId, database, collection, docJson); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index c011ef58a..c24592567 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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; diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 8971c70bc..46dedff28 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -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, + pub affected_rows: u64, +} + pub async fn connect(url: &str, timeout: Duration, idle_timeout: Duration) -> Result { 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 { + 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 { + let remaining = after.iter().map(|index| index.name.as_str()).collect::>(); + 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 { + 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 { + 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!({ diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index 42c984eb0..310370f1c 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -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 { + 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, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 101b17bc3..7a4d1b541 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -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)) diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 5796cfb83..83a51e8c1 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -107,6 +107,16 @@ pub struct MongoCreateIndexRequest { pub options_json: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MongoDropIndexesRequest { + pub connection_id: String, + pub database: String, + pub collection: String, + pub indexes_json: Option, + 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>, + Json(req): Json, +) -> Result, 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>, Json(req): Json, diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 59dbd3998..ba7b8b7d3 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -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", diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 35cfb7f37..1a356fc49 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -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()); diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts index e6ed07706..772ebf392 100644 --- a/packages/node-core/src/database.ts +++ b/packages/node-core/src/database.ts @@ -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 { 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 { + 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; diff --git a/packages/node-core/src/web-backend.ts b/packages/node-core/src/web-backend.ts index d395f7de4..24f066770 100644 --- a/packages/node-core/src/web-backend.ts +++ b/packages/node-core/src/web-backend.ts @@ -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({ diff --git a/packages/node-core/tests/mongo-query.test.ts b/packages/node-core/tests/mongo-query.test.ts index 5d9f9fdd1..3ba6b338f 100644 --- a/packages/node-core/tests/mongo-query.test.ts +++ b/packages/node-core/tests/mongo-query.test.ts @@ -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( diff --git a/src-tauri/src/commands/mcp_bridge.rs b/src-tauri/src/commands/mcp_bridge.rs index dcc131643..807d4204d 100644 --- a/src-tauri/src/commands/mcp_bridge.rs +++ b/src-tauri/src/commands/mcp_bridge.rs @@ -81,6 +81,15 @@ struct MongoCreateIndexRequest { options_json: Option, } +#[derive(Deserialize)] +struct MongoDropIndexesRequest { + connection_name: String, + database: Option, + collection: String, + indexes_json: Option, + single: bool, +} + #[derive(Deserialize)] struct MongoInsertDocumentsRequest { connection_name: String, @@ -182,6 +191,8 @@ pub fn start(app_handle: AppHandle, state: Arc, 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, body: &str, strea } } +async fn handle_mongo_drop_indexes_data(state: &Arc, 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, body: &str, stream: &mut tokio::net::TcpStream) { let req: MongoInsertDocumentsRequest = match serde_json::from_str(body) { Ok(r) => r, diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index 63633b8aa..49ea86804 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -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>, + connection_id: String, + database: String, + collection: String, + indexes_json: Option, + single: bool, +) -> Result { + 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>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3dd1f42e7..31b8d0961 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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,