From 2db28fc236ea51afa5448a0d64d8c97ced96c42c Mon Sep 17 00:00:00 2001 From: Loc Nguyen <113083148+thailoc-dev@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:12:39 +0700 Subject: [PATCH] feat(mongodb): support db.collection.renameCollection() --- .../java/com/dbx/agent/AgentProtocol.java | 6 +++ .../src/main/resources/agent-protocol-v1.json | 4 +- .../src/main/resources/agent-protocol-v2.json | 4 +- .../com/dbx/agent/mongodb/MongoAgent.java | 28 ++++++++++ .../com/dbx/agent/mongodb/MongoAgentTest.java | 28 ++++++++++ apps/desktop/src/lib/mongo/mongoCompletion.ts | 2 + .../src/lib/mongo/mongoShellCommand.ts | 31 ++++++++++- apps/desktop/src/stores/queryStore.ts | 5 ++ crates/dbx-core/assets/agent-protocol-v1.json | 4 +- crates/dbx-core/src/query/mongo_ops.rs | 22 +++++++- .../dbx-drivers/assets/agent-protocol-v2.json | 4 +- crates/dbx-drivers/src/db/agent_driver.rs | 22 ++++++-- crates/dbx-drivers/src/mongo_shell.rs | 54 +++++++++++++++++++ crates/dbx-mcp/src/backend.rs | 14 +++++ packages/app-tests/mongoCompletion.test.ts | 3 +- packages/app-tests/mongoShellCommand.test.ts | 25 +++++++-- 16 files changed, 239 insertions(+), 17 deletions(-) diff --git a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java index 1b6e849fef..c17b451944 100644 --- a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java +++ b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java @@ -75,6 +75,7 @@ public final class AgentProtocol { public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents"; public static final String MONGO_METHOD_REPLACE_DOCUMENT = "replace_document"; public static final String MONGO_METHOD_BULK_WRITE = "bulk_write"; + public static final String MONGO_METHOD_RENAME_COLLECTION = "rename_collection"; public static final String MONGO_METHOD_DELETE_DOCUMENT = "delete_document"; public static final String MONGO_METHOD_DELETE_DOCUMENTS = "delete_documents"; public static final String MONGO_METHOD_RUN_COMMAND = "run_command"; @@ -134,6 +135,7 @@ public final class AgentProtocol { public static final String CAPABILITY_MONGO_INSERT_DOCUMENTS = "mongo_insert_documents"; public static final String CAPABILITY_MONGO_REPLACE_DOCUMENT = "mongo_replace_document"; public static final String CAPABILITY_MONGO_BULK_WRITE = "mongo_bulk_write"; + public static final String CAPABILITY_MONGO_RENAME_COLLECTION = "mongo_rename_collection"; public static final String CAPABILITY_MONGO_FIND_CURSOR = "mongo_find_cursor"; public static final String CAPABILITY_MULTI_SESSION = "multi_session"; public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1"; @@ -173,6 +175,7 @@ public final class AgentProtocol { CAPABILITY_MONGO_INSERT_DOCUMENTS, CAPABILITY_MONGO_REPLACE_DOCUMENT, CAPABILITY_MONGO_BULK_WRITE, + CAPABILITY_MONGO_RENAME_COLLECTION, CAPABILITY_MONGO_FIND_CURSOR )); @@ -239,6 +242,7 @@ public final class AgentProtocol { mongoCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS); mongoCapabilities.add(CAPABILITY_MONGO_REPLACE_DOCUMENT); mongoCapabilities.add(CAPABILITY_MONGO_BULK_WRITE); + mongoCapabilities.add(CAPABILITY_MONGO_RENAME_COLLECTION); mongoCapabilities.add(CAPABILITY_MONGO_FIND_CURSOR); MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities); @@ -249,6 +253,7 @@ public final class AgentProtocol { mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS); mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_REPLACE_DOCUMENT); mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_BULK_WRITE); + mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_RENAME_COLLECTION); mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_FIND_CURSOR); MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities); @@ -293,6 +298,7 @@ public final class AgentProtocol { MONGO_METHOD_UPDATE_DOCUMENTS, MONGO_METHOD_REPLACE_DOCUMENT, MONGO_METHOD_BULK_WRITE, + MONGO_METHOD_RENAME_COLLECTION, MONGO_METHOD_DELETE_DOCUMENT, MONGO_METHOD_DELETE_DOCUMENTS, MONGO_METHOD_RUN_COMMAND, diff --git a/agents/common/src/main/resources/agent-protocol-v1.json b/agents/common/src/main/resources/agent-protocol-v1.json index 1df74f9a48..1c4ba1e65e 100644 --- a/agents/common/src/main/resources/agent-protocol-v1.json +++ b/agents/common/src/main/resources/agent-protocol-v1.json @@ -2,7 +2,7 @@ "protocolVersion": 1, "handshakeMethod": "handshake", "handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"], - "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_find_cursor"], + "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor"], "capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "commonMethods": [ @@ -42,6 +42,6 @@ "disconnect", "shutdown" ], - "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], + "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "rename_collection", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], "kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"] } diff --git a/agents/common/src/main/resources/agent-protocol-v2.json b/agents/common/src/main/resources/agent-protocol-v2.json index 8ffe635f9e..7a0493d6a4 100644 --- a/agents/common/src/main/resources/agent-protocol-v2.json +++ b/agents/common/src/main/resources/agent-protocol-v2.json @@ -2,7 +2,7 @@ "protocolVersion": 2, "handshakeMethod": "handshake", "handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"], - "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_find_cursor", "multi_session", "structured_error_v1"], + "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1"], "capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"], "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"], "commonMethods": [ @@ -46,7 +46,7 @@ "disconnect", "shutdown" ], - "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], + "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "rename_collection", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], "kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"], "sessionField": "agentSessionId", "cursorSessionField": "sessionId" diff --git a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java index 3be93548c5..a6e7164df4 100644 --- a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java +++ b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java @@ -11,6 +11,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.mongodb.ConnectionString; import com.mongodb.MongoBulkWriteException; +import com.mongodb.MongoNamespace; import com.mongodb.MongoCredential; import com.mongodb.MongoClientSettings; import com.mongodb.ServerAddress; @@ -1166,6 +1167,32 @@ public final class MongoAgent { return result; } + private static Object renameCollection(JsonObject params) { + MongoClient c = requireClient(); + String database = params.get("database").getAsString(); + String collection = params.get("collection").getAsString(); + String newName = params.get("new_name").getAsString(); + requireRenameableCollectionNames(collection, newName); + c.getDatabase(database).getCollection(collection).renameCollection(new MongoNamespace(database, newName)); + return Collections.singletonMap("ok", true); + } + + /** Mirrors the native driver's checks so both paths reject the same names before asking the server. */ + static void requireRenameableCollectionNames(String collection, String newName) { + if (collection == null || collection.isBlank()) { + throw new IllegalArgumentException("Collection name is required"); + } + if (newName == null || newName.isBlank()) { + throw new IllegalArgumentException("New collection name is required"); + } + if (collection.equals(newName)) { + throw new IllegalArgumentException("New collection name must differ from the current name"); + } + if (collection.startsWith("system.") || newName.startsWith("system.")) { + throw new IllegalArgumentException("System collections cannot be renamed"); + } + } + private static Object dropCollection(JsonObject params) { MongoClient c = requireClient(); String database = params.get("database").getAsString(); @@ -2280,6 +2307,7 @@ public final class MongoAgent { case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENTS -> updateDocuments(params); case AgentProtocol.MONGO_METHOD_REPLACE_DOCUMENT -> replaceDocument(params); case AgentProtocol.MONGO_METHOD_BULK_WRITE -> bulkWrite(params); + case AgentProtocol.MONGO_METHOD_RENAME_COLLECTION -> renameCollection(params); case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENT -> deleteDocument(params); case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENTS -> deleteDocuments(params); case AgentProtocol.MONGO_METHOD_RUN_COMMAND -> runCommand(params); diff --git a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java index a33e6f4a8b..a2a9682b13 100644 --- a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java +++ b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java @@ -1387,6 +1387,34 @@ class MongoAgentTest { } } + @Test + void renameCollectionMethodIsRecognizedOverJsonRpc() { + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"rename_collection\"," + + "\"params\":{\"database\":\"app\",\"collection\":\"orders\",\"new_name\":\"orders_2024\"}}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertEquals(13, json.get("id").getAsInt()); + assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString()); + assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_RENAME_COLLECTION)); + assertTrue(AgentProtocol.MONGO_LEGACY_CAPABILITIES.contains(AgentProtocol.CAPABILITY_MONGO_RENAME_COLLECTION)); + } + + @Test + void rejectsUnrenameableCollectionNames() { + MongoAgent.requireRenameableCollectionNames("orders", "orders_2024"); + for (String[] item : new String[][] { + {"", "x", "Collection name is required"}, + {"orders", "", "New collection name is required"}, + {"orders", "orders", "must differ"}, + {"system.users", "x", "System collections cannot be renamed"}, + {"orders", "system.x", "System collections cannot be renamed"}, + }) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> MongoAgent.requireRenameableCollectionNames(item[0], item[1])); + assertTrue(error.getMessage().contains(item[2]), error.getMessage()); + } + } + @Test void parsesBulkWriteOptions() { assertTrue(MongoAgent.bulkWriteOptionsForWrite(null).isOrdered()); diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index 962c96c219..49e7405633 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -108,6 +108,7 @@ const COLLECTION_METHODS = [ { 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()" }, + { label: "renameCollection", detail: "Rename the collection", apply: 'renameCollection("${newName}")' }, { label: "drop", detail: "Drop the collection", apply: "drop()" }, ] as const; @@ -136,6 +137,7 @@ const COLLECTION_METHOD_BOOST: Record<(typeof COLLECTION_METHODS)[number]["label dataSize: 75, storageSize: 70, totalIndexSize: 65, + renameCollection: 55, dropIndex: 50, dropIndexes: 45, drop: 30, diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 526ca9abd5..0da3179251 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -50,6 +50,7 @@ const COLLECTION_METHOD_SHAPES: Record = { updateMany: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] }, replaceOne: { expects: "a filter, a replacement document, and optional options", roles: ["filter", "replacement", "options"] }, bulkWrite: { expects: "an array of operations and optional options", roles: ["operations", "options"] }, + renameCollection: { expects: "the new collection name", roles: ["newName"] }, deleteOne: { expects: "a filter", roles: ["filter"] }, deleteMany: { expects: "a filter", roles: ["filter"] }, findOneAndUpdate: { expects: "a filter, an update, and optional options", roles: ["filter", "update", "options"] }, @@ -80,6 +81,7 @@ const SUPPORTED_COLLECTION_METHODS = [ "updateMany", "replaceOne", "bulkWrite", + "renameCollection", "deleteOne", "deleteMany", "findOneAndUpdate", @@ -168,6 +170,12 @@ function diagnoseMongoCommand(source: string): string | null { return `${method}() returns a result, not a cursor, so nothing can be chained after it: "${tail.length > 40 ? `${tail.slice(0, 40)}…` : tail}".`; } if (tail) return `Unexpected text after ${method}(...): "${tail.length > 40 ? `${tail.slice(0, 40)}…` : tail}".`; + if (method === "renameCollection") { + if (args.length > 1) return "renameCollection() dropTarget is not supported; drop the target collection first."; + const newName = args[0] ? /^(["'])([^"']*)\1$/.exec(args[0].trim())?.[2] : undefined; + if (newName !== undefined && (!newName.trim() || newName.includes("$"))) return "renameCollection() requires a valid collection name."; + if (newName !== undefined && shape.groups?.collection === newName) return "renameCollection() new name must differ from the current name."; + } if (method === "bulkWrite" && args[0]) { const operations = normalizeJsonArgument(args[0]); const problem = operations ? validateBulkWriteOperations(operations) : null; @@ -287,7 +295,7 @@ export interface MongoDistinctCommand { filter?: string; } -type MongoWriteKind = "runCommand" | "insert" | "update" | "replace" | "bulkWrite" | "delete" | "createIndex" | "createUser" | "dropIndex" | "dropIndexes" | "dropCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete"; +type MongoWriteKind = "runCommand" | "insert" | "update" | "replace" | "bulkWrite" | "delete" | "createIndex" | "createUser" | "dropIndex" | "dropIndexes" | "dropCollection" | "renameCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete"; export type MongoCommand = | ({ kind: "find" } & MongoFindCommand) @@ -312,6 +320,7 @@ export type MongoCommand = | { kind: "dropIndex"; collection: string; index: string } | { kind: "dropIndexes"; collection: string; indexes?: string } | { kind: "dropCollection"; collection: string } + | { kind: "renameCollection"; collection: string; newName: string } | { kind: "findOneAndUpdate"; collection: string; filter: string; update: string; options?: string } | { kind: "findOneAndReplace"; collection: string; filter: string; replacement: string; options?: string } | { kind: "findOneAndDelete"; collection: string; filter: string; options?: string }; @@ -889,6 +898,17 @@ export function parseMongoWriteCommand(input: string): MongoWriteCommand | null return indexes !== null ? { kind: "dropIndexes", collection: dropIndexes.collection, ...(indexes ? { indexes } : {}) } : null; } + const renameCollection = parseCollectionMethodTarget(source, "renameCollection"); + if (renameCollection) { + const args = parseMethodArgs(source, renameCollection.methodCallIndex); + // A second argument would be dropTarget, which deletes an existing collection of the + // new name; it is rejected (see the diagnostics) rather than silently ignored. + if (!args || args.length !== 1) return null; + const newName = /^(["'])([^"'$]+)\1$/.exec(args[0]!.trim())?.[2]?.trim(); + if (!newName || newName === renameCollection.collection) return null; + return { kind: "renameCollection", collection: renameCollection.collection, newName }; + } + const dropCollection = parseCollectionMethodTarget(source, "drop"); if (dropCollection) { const args = parseMethodArgs(source, dropCollection.methodCallIndex); @@ -1184,6 +1204,15 @@ export function mongoUseToQueryResult(database: string, executionTimeMs: number) }; } +export function mongoScalarToQueryResult(column: string, value: string, executionTimeMs: number): QueryResult { + return { + columns: [column], + rows: [[value]], + affected_rows: 1, + execution_time_ms: Math.max(0, Math.round(executionTimeMs)), + }; +} + export function mongoVersionToQueryResult(version: string, executionTimeMs: number): QueryResult { return { columns: ["version"], diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 4689c0b59f..1d927c31a4 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -33,6 +33,7 @@ import { mongoUseToQueryResult, mongoVersionToQueryResult, mongoBulkWriteToQueryResult, + mongoScalarToQueryResult, mongoWriteToQueryResult, splitMongoCommandRanges, type MongoAggregateSafetyOptions, @@ -6745,6 +6746,7 @@ export const useQueryStore = defineStore("query", () => { case "createUser": case "dropIndex": case "dropIndexes": + case "renameCollection": case "dropCollection": { if (options?.mongoSafety) { const safety = evaluateMongoWriteSafety(mongoCommand, options.mongoSafety); @@ -6782,6 +6784,9 @@ export const useQueryStore = defineStore("query", () => { } finally { await refreshLoadedMongoIndexesAfterMutation(executionConnectionId, currentDatabase, mongoCommand.collection, traceId); } + } else if (mongoCommand.kind === "renameCollection") { + await api.mongoRenameCollection(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.newName); + allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoScalarToQueryResult("renamed", `${mongoCommand.collection} -> ${mongoCommand.newName}`, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "dropCollection") { await api.mongoDropCollection(executionConnectionId, currentDatabase, mongoCommand.collection); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(1, performance.now() - commandStartedAt)))); diff --git a/crates/dbx-core/assets/agent-protocol-v1.json b/crates/dbx-core/assets/agent-protocol-v1.json index 1df74f9a48..1c4ba1e65e 100644 --- a/crates/dbx-core/assets/agent-protocol-v1.json +++ b/crates/dbx-core/assets/agent-protocol-v1.json @@ -2,7 +2,7 @@ "protocolVersion": 1, "handshakeMethod": "handshake", "handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"], - "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_find_cursor"], + "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor"], "capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "commonMethods": [ @@ -42,6 +42,6 @@ "disconnect", "shutdown" ], - "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], + "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "rename_collection", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], "kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"] } diff --git a/crates/dbx-core/src/query/mongo_ops.rs b/crates/dbx-core/src/query/mongo_ops.rs index bef7c44aaf..f3de681da5 100644 --- a/crates/dbx-core/src/query/mongo_ops.rs +++ b/crates/dbx-core/src/query/mongo_ops.rs @@ -95,7 +95,23 @@ pub async fn mongo_rename_collection_core( let pool = state.pool_handle(connection_id).await.ok_or("Not found")?; match &pool { PoolKind::MongoDb(client) => mongo_driver::rename_collection(client, database, collection, new_name).await, - PoolKind::Agent(_) => Err("MongoDB legacy agent does not support rename collection".to_string()), + PoolKind::Agent(client) => { + let mut client = client.lock().await; + if !client.supports_capability(AgentCapability::MongoRenameCollection) { + return Err( + "MongoDB Legacy Agent does not support renameCollection; upgrade or reinstall the MongoDB Legacy driver" + .to_string(), + ); + } + let _: serde_json::Value = client + .mongo_rename_collection(serde_json::json!({ + "database": database, + "collection": collection, + "new_name": new_name, + })) + .await?; + Ok(()) + } _ => Err("Not a MongoDB connection".to_string()), } } @@ -1200,6 +1216,10 @@ pub async fn execute_mongo_command_core( mongo_drop_collection_core(state, connection_id, database, collection).await?; Ok(affected_query_result(1)) } + MongoCommand::RenameCollection { collection, new_name } => { + mongo_rename_collection_core(state, connection_id, database, collection, new_name).await?; + Ok(scalar_query_result("renamed", Value::String(format!("{collection} -> {new_name}")))) + } MongoCommand::FindOneAndUpdate { collection, filter, update, options } => { let result = mongo_find_one_and_update_core( state, diff --git a/crates/dbx-drivers/assets/agent-protocol-v2.json b/crates/dbx-drivers/assets/agent-protocol-v2.json index 8ffe635f9e..7a0493d6a4 100644 --- a/crates/dbx-drivers/assets/agent-protocol-v2.json +++ b/crates/dbx-drivers/assets/agent-protocol-v2.json @@ -2,7 +2,7 @@ "protocolVersion": 2, "handshakeMethod": "handshake", "handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"], - "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_find_cursor", "multi_session", "structured_error_v1"], + "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1"], "capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"], "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"], "commonMethods": [ @@ -46,7 +46,7 @@ "disconnect", "shutdown" ], - "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], + "mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "replace_document", "bulk_write", "rename_collection", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"], "kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"], "sessionField": "agentSessionId", "cursorSessionField": "sessionId" diff --git a/crates/dbx-drivers/src/db/agent_driver.rs b/crates/dbx-drivers/src/db/agent_driver.rs index 79d82403b8..0a106dc693 100644 --- a/crates/dbx-drivers/src/db/agent_driver.rs +++ b/crates/dbx-drivers/src/db/agent_driver.rs @@ -1043,6 +1043,7 @@ pub enum AgentCapability { MongoInsertDocuments, MongoReplaceDocument, MongoBulkWrite, + MongoRenameCollection, MongoFindCursor, MultiSession, StructuredErrorV1, @@ -1126,7 +1127,7 @@ fn parse_agent_rpc_error_header(header: &str) -> (Option, String) { } impl AgentCapability { - pub const ALL: [Self; 27] = [ + pub const ALL: [Self; 28] = [ Self::Connect, Self::TestConnection, Self::Metadata, @@ -1151,6 +1152,7 @@ impl AgentCapability { Self::MongoInsertDocuments, Self::MongoReplaceDocument, Self::MongoBulkWrite, + Self::MongoRenameCollection, Self::MongoFindCursor, Self::MultiSession, Self::StructuredErrorV1, @@ -1182,6 +1184,7 @@ impl AgentCapability { Self::MongoInsertDocuments => "mongo_insert_documents", Self::MongoReplaceDocument => "mongo_replace_document", Self::MongoBulkWrite => "mongo_bulk_write", + Self::MongoRenameCollection => "mongo_rename_collection", Self::MongoFindCursor => "mongo_find_cursor", Self::MultiSession => "multi_session", Self::StructuredErrorV1 => "structured_error_v1", @@ -1387,6 +1390,7 @@ pub enum MongoAgentMethod { UpdateDocuments, ReplaceDocument, BulkWrite, + RenameCollection, DeleteDocument, DeleteDocuments, RunCommand, @@ -1396,7 +1400,7 @@ pub enum MongoAgentMethod { } impl MongoAgentMethod { - pub const ALL: [Self; 27] = [ + pub const ALL: [Self; 28] = [ Self::ListDatabases, Self::ListCollections, Self::FindDocuments, @@ -1418,6 +1422,7 @@ impl MongoAgentMethod { Self::UpdateDocuments, Self::ReplaceDocument, Self::BulkWrite, + Self::RenameCollection, Self::DeleteDocument, Self::DeleteDocuments, Self::RunCommand, @@ -1449,6 +1454,7 @@ impl MongoAgentMethod { Self::UpdateDocuments => "update_documents", Self::ReplaceDocument => "replace_document", Self::BulkWrite => "bulk_write", + Self::RenameCollection => "rename_collection", Self::DeleteDocument => "delete_document", Self::DeleteDocuments => "delete_documents", Self::RunCommand => "run_command", @@ -2859,6 +2865,13 @@ impl AgentDriverClient { self.call_mongo_method(MongoAgentMethod::BulkWrite, params).await } + pub async fn mongo_rename_collection( + &mut self, + params: Value, + ) -> Result { + self.call_mongo_method(MongoAgentMethod::RenameCollection, params).await + } + pub async fn mongo_delete_document( &mut self, params: Value, @@ -3016,6 +3029,7 @@ pub fn agent_supports_capability(handshake: Option<&AgentHandshake>, capability: | AgentCapability::MongoInsertDocuments | AgentCapability::MongoReplaceDocument | AgentCapability::MongoBulkWrite + | AgentCapability::MongoRenameCollection | AgentCapability::MongoFindCursor ) { return handshake.map(|value| value.supports(capability)).unwrap_or(false); @@ -5052,10 +5066,11 @@ for line in sys.stdin: assert_eq!(AgentCapability::MongoInsertDocuments.as_str(), "mongo_insert_documents"); assert_eq!(AgentCapability::MongoReplaceDocument.as_str(), "mongo_replace_document"); assert_eq!(AgentCapability::MongoBulkWrite.as_str(), "mongo_bulk_write"); + assert_eq!(AgentCapability::MongoRenameCollection.as_str(), "mongo_rename_collection"); assert_eq!(AgentCapability::MongoFindCursor.as_str(), "mongo_find_cursor"); assert_eq!(AgentCapability::MultiSession.as_str(), "multi_session"); assert_eq!(AgentCapability::StructuredErrorV1.as_str(), "structured_error_v1"); - assert_eq!(AgentCapability::ALL.len(), 27); + assert_eq!(AgentCapability::ALL.len(), 28); } #[test] @@ -5119,6 +5134,7 @@ for line in sys.stdin: assert_eq!(MongoAgentMethod::UpdateDocuments.as_str(), "update_documents"); assert_eq!(MongoAgentMethod::ReplaceDocument.as_str(), "replace_document"); assert_eq!(MongoAgentMethod::BulkWrite.as_str(), "bulk_write"); + assert_eq!(MongoAgentMethod::RenameCollection.as_str(), "rename_collection"); assert_eq!(MongoAgentMethod::DeleteDocument.as_str(), "delete_document"); assert_eq!(MongoAgentMethod::DeleteDocuments.as_str(), "delete_documents"); assert_eq!(MongoAgentMethod::StartFindCursor.as_str(), "start_find_cursor"); diff --git a/crates/dbx-drivers/src/mongo_shell.rs b/crates/dbx-drivers/src/mongo_shell.rs index c77054b32e..95876e2f6d 100644 --- a/crates/dbx-drivers/src/mongo_shell.rs +++ b/crates/dbx-drivers/src/mongo_shell.rs @@ -78,6 +78,12 @@ pub enum MongoCommand { DropIndexes { collection: String, indexes: Option, single: bool }, #[serde(rename = "dropCollection")] DropCollection { collection: String }, + #[serde(rename = "renameCollection")] + RenameCollection { + collection: String, + #[serde(rename = "newName")] + new_name: String, + }, #[serde(rename = "findOneAndUpdate")] FindOneAndUpdate { collection: String, filter: String, update: String, options: Option }, #[serde(rename = "findOneAndReplace")] @@ -108,6 +114,7 @@ impl MongoCommand { | Self::CreateIndex { .. } | Self::DropIndexes { .. } | Self::DropCollection { .. } + | Self::RenameCollection { .. } | Self::FindOneAndUpdate { .. } | Self::FindOneAndReplace { .. } | Self::FindOneAndDelete { .. } @@ -863,6 +870,27 @@ pub fn parse(input: &str) -> Result { single: false, }); } + if let Some((args, tail)) = method_call(source, prefix_end, "renameCollection") { + if !tail.is_empty() || args.is_empty() { + return Err("MongoDB renameCollection() requires the new collection name.".to_string()); + } + if args.len() > 1 { + // The second argument is dropTarget, which would delete an existing collection + // of the new name; reject it rather than silently ignore it. + return Err( + "MongoDB renameCollection() dropTarget is not supported; drop the target collection first.".to_string() + ); + } + let new_name = parse_string_arg(&args[0])?; + if new_name.trim().is_empty() || new_name.contains('$') { + return Err("MongoDB renameCollection() requires a valid collection name.".to_string()); + } + if new_name == collection { + return Err("MongoDB renameCollection() new name must differ from the current name.".to_string()); + } + return Ok(MongoCommand::RenameCollection { collection, new_name }); + } + if let Some((args, tail)) = method_call(source, prefix_end, "drop") { if !tail.is_empty() || !args.is_empty() { return Err("Invalid MongoDB drop() command.".to_string()); @@ -2219,6 +2247,32 @@ mod tests { } } + #[test] + fn parses_rename_collection() { + let command = parse(r#"db.orders.renameCollection("orders_2024");"#).unwrap(); + assert_eq!( + command, + MongoCommand::RenameCollection { collection: "orders".to_string(), new_name: "orders_2024".to_string() } + ); + assert!(command.is_mutating()); + assert!(!command.is_dangerous()); + assert_eq!(serde_json::to_value(&command).unwrap()["newName"], "orders_2024"); + assert!(matches!(parse(r#"db["a-b"].renameCollection('c')"#).unwrap(), MongoCommand::RenameCollection { .. })); + + for (source, expected) in [ + ("db.orders.renameCollection()", "requires the new collection name"), + ("db.orders.renameCollection(1)", "must be a string"), + (r#"db.orders.renameCollection("")"#, "valid collection name"), + (r#"db.orders.renameCollection("a$b")"#, "valid collection name"), + (r#"db.orders.renameCollection("orders")"#, "must differ"), + (r#"db.orders.renameCollection("x", true)"#, "dropTarget is not supported"), + (r#"db.orders.renameCollection("x").y()"#, "requires the new collection name"), + ] { + let error = parse(source).unwrap_err(); + assert!(error.contains(expected), "{source}\n expected: {expected}\n got: {error}"); + } + } + #[test] fn parses_show_databases_aliases_as_read_only() { for source in [ diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 7dec69a779..fa78fecf0d 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -2020,6 +2020,20 @@ impl DbxBackend for WebBackend { .collect::>(); Ok(mongo_drop_indexes_query_result(dropped_names, failures, affected_rows_from_value(&value))) } + MongoCommand::RenameCollection { collection, new_name } => { + self.request( + reqwest::Method::POST, + "/api/mongo/rename-collection", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "newName": new_name, + })), + ) + .await?; + Ok(scalar_query_result("renamed", Value::String(format!("{collection} -> {new_name}")))) + } MongoCommand::DropCollection { collection } => { self.request( reqwest::Method::POST, diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index 54ba719309..3e59477c04 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -502,8 +502,9 @@ test("suggests only helpers the shell parser accepts", () => { assert.ok(methodLabels.includes("estimatedDocumentCount")); assert.ok(methodLabels.includes("replaceOne")); assert.ok(methodLabels.includes("bulkWrite")); + assert.ok(methodLabels.includes("renameCollection")); // Suggesting a helper DBX cannot run just hands the user a command that fails. - for (const unsupported of ["renameCollection", "mapReduce", "watch"]) { + for (const unsupported of ["mapReduce", "watch", "validate"]) { assert.equal(methodLabels.includes(unsupported), false, `${unsupported} is not executable`); } // Cursor methods are not collection methods. diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 5bf58cb2f0..542818f354 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -747,6 +747,25 @@ test("parseMongoWriteCommand accepts unquoted insert and update commands", () => }); }); +test("parseMongoWriteCommand reads renameCollection", () => { + assert.deepEqual(parseMongoWriteCommand('db.orders.renameCollection("orders_2024");'), { kind: "renameCollection", collection: "orders", newName: "orders_2024" }); + assert.equal(parseMongoWriteCommand("db[\"a-b\"].renameCollection('c')")?.kind, "renameCollection"); + assert.equal(parseMongoWriteCommand('db.getCollection("a").renameCollection("b")')?.kind, "renameCollection"); + + for (const [source, expected] of [ + ["db.orders.renameCollection()", /expects the new collection name/], + ["db.orders.renameCollection(1)", /expects the new collection name/], + ['db.orders.renameCollection("")', /requires a valid collection name/], + ['db.orders.renameCollection("a$b")', /requires a valid collection name/], + ['db.orders.renameCollection("orders")', /must differ from the current name/], + ['db.orders.renameCollection("x", true)', /dropTarget is not supported; drop the target collection first/], + ['db.orders.renameCollection("x").y()', /Unexpected text after renameCollection/], + ] as const) { + assert.equal(parseMongoCommand(source), null, source); + assert.match(describeMongoCommandParseFailure(source), expected, source); + } +}); + test("parseMongoWriteCommand reads bulkWrite with every operation kind", () => { const command = parseMongoWriteCommand(`db.orders.bulkWrite([ { insertOne: { document: { sku: "A1", stock: 1 } } }, @@ -1383,10 +1402,10 @@ test("describeMongoCommandParseFailure explains the argument shape a known metho }); test("describeMongoCommandParseFailure names unsupported methods and points at alternatives", () => { - const rename = describeMongoCommandParseFailure('db.c.renameCollection("d")'); - assert.match(rename, /^Collection method renameCollection\(\) is not supported\. Supported collection methods: find, findOne/); + const mapReduce = describeMongoCommandParseFailure("db.c.mapReduce()"); + assert.match(mapReduce, /^Collection method mapReduce\(\) is not supported\. Supported collection methods: find, findOne/); // Bracket and getCollection targets are recognised too. - assert.match(describeMongoCommandParseFailure('db["my-coll"].renameCollection("x")'), /renameCollection\(\) is not supported/); + assert.match(describeMongoCommandParseFailure('db["my-coll"].validate()'), /validate\(\) is not supported/); assert.match(describeMongoCommandParseFailure('db.getCollection("my-coll").watch()'), /watch\(\) is not supported/); assert.match(describeMongoCommandParseFailure('db.createCollection("c")'), /db\.createCollection\(\) is not supported; collections are created on first insert/);