feat(mongodb): support db.collection.renameCollection()

This commit is contained in:
Loc Nguyen authored and GitHub committed 2026-09-18 14:12:39 +08:00
1 parent 392138f59a
commit 2db28fc236
16 files changed
+239 -17

No files matched your search

@@ -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,
@@ -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"]
}
@@ -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"
@@ -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);
@@ -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());
@@ -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,
@@ -50,6 +50,7 @@ const COLLECTION_METHOD_SHAPES: Record<string, MongoMethodShape> = {
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"],
+5
View File
@@ -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))));
@@ -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"]
}
+21 -1
View File
@@ -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,
@@ -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"
+19 -3
View File
@@ -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<i64>, 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<T: DeserializeOwned + Send + 'static>(
&mut self,
params: Value,
) -> Result<T, String> {
self.call_mongo_method(MongoAgentMethod::RenameCollection, params).await
}
pub async fn mongo_delete_document<T: DeserializeOwned + Send + 'static>(
&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");
+54
View File
@@ -78,6 +78,12 @@ pub enum MongoCommand {
DropIndexes { collection: String, indexes: Option<String>, 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<String> },
#[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<MongoCommand, String> {
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 [
+14
View File
@@ -2020,6 +2020,20 @@ impl DbxBackend for WebBackend {
.collect::<Vec<_>>();
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,
+2 -1
View File
@@ -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.
+22 -3
View File
@@ -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/);