From 5fe3b5b435839b9d52c9d625aebaf42ae56f90e0 Mon Sep 17 00:00:00 2001 From: onenewcode Date: Mon, 10 Aug 2026 15:53:00 +0800 Subject: [PATCH] feat(mongo): add collection clone --- .../java/com/dbx/agent/AgentProtocol.java | 8 +- .../src/main/resources/agent-protocol-v1.json | 4 +- .../src/main/resources/agent-protocol-v2.json | 4 +- .../com/dbx/agent/mongodb/MongoAgent.java | 229 ++++++++++++- .../com/dbx/agent/mongodb/MongoAgentTest.java | 306 ++++++++++++++++++ .../sidebar/SidebarTreeItemDialogs.vue | 23 ++ .../sidebar/SidebarTreeRuntimeHost.vue | 21 ++ .../sidebar/sidebarTreeDialogState.ts | 8 + ...abaseSpecificMutationRuntime.mongo.spec.ts | 57 +++- ...eSidebarDatabaseSpecificMutationRuntime.ts | 67 ++++ apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/ko.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 10 + apps/desktop/src/lib/backend/tauri.ts | 14 + .../__tests__/mongoCollectionMutation.spec.ts | 20 ++ .../lib/sidebar/mongoCollectionMutation.ts | 21 ++ crates/dbx-core/assets/agent-protocol-v1.json | 4 +- crates/dbx-core/assets/agent-protocol-v2.json | 4 +- crates/dbx-core/src/db/agent_driver.rs | 31 +- crates/dbx-core/src/db/mongo_driver.rs | 150 +++++++++ crates/dbx-core/src/mongo_ops.rs | 78 ++++- .../dbx-core/tests/live_mongodb_find_one.rs | 55 +++- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/mongo.rs | 68 +++- packages/app-tests/i18nAutofillParser.test.ts | 2 +- src-tauri/src/commands/mongo_cmd.rs | 19 ++ src-tauri/src/lib.rs | 1 + 34 files changed, 1217 insertions(+), 21 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 541207ecec..9e647c05fd 100644 --- a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java +++ b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java @@ -61,6 +61,7 @@ public final class AgentProtocol { public static final String MONGO_METHOD_CREATE_USER = "create_user"; public static final String MONGO_METHOD_DROP_INDEXES = "drop_indexes"; public static final String MONGO_METHOD_DROP_COLLECTION = "drop_collection"; + public static final String MONGO_METHOD_CLONE_COLLECTION = "clone_collection"; public static final String MONGO_METHOD_DROP_DATABASE = "drop_database"; public static final String MONGO_METHOD_INSERT_DOCUMENT = "insert_document"; public static final String MONGO_METHOD_UPDATE_DOCUMENT = "update_document"; @@ -118,6 +119,7 @@ public final class AgentProtocol { public static final String CAPABILITY_ETCD_LEASE = "etcd_lease"; public static final String CAPABILITY_ETCD_AUTH = "etcd_auth"; public static final String CAPABILITY_MONGO_DROP_DATABASE = "mongo_drop_database"; + public static final String CAPABILITY_MONGO_CLONE_COLLECTION = "mongo_clone_collection"; public static final String CAPABILITY_MULTI_SESSION = "multi_session"; public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1"; @@ -150,7 +152,8 @@ public final class AgentProtocol { CAPABILITY_ETCD_WATCH, CAPABILITY_ETCD_LEASE, CAPABILITY_ETCD_AUTH, - CAPABILITY_MONGO_DROP_DATABASE + CAPABILITY_MONGO_DROP_DATABASE, + CAPABILITY_MONGO_CLONE_COLLECTION )); public static final List MULTI_SESSION_CAPABILITIES; @@ -208,10 +211,12 @@ public final class AgentProtocol { List mongoCapabilities = new java.util.ArrayList<>(CAPABILITIES); mongoCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE); + mongoCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION); MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities); List mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES); mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE); + mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION); MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities); List jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES); @@ -247,6 +252,7 @@ public final class AgentProtocol { MONGO_METHOD_CREATE_USER, MONGO_METHOD_DROP_INDEXES, MONGO_METHOD_DROP_COLLECTION, + MONGO_METHOD_CLONE_COLLECTION, MONGO_METHOD_DROP_DATABASE, MONGO_METHOD_INSERT_DOCUMENT, MONGO_METHOD_UPDATE_DOCUMENT, diff --git a/agents/common/src/main/resources/agent-protocol-v1.json b/agents/common/src/main/resources/agent-protocol-v1.json index 13077d2ee0..7b5b10f6ec 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"], + "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"], "capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "commonMethods": [ @@ -39,6 +39,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", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"], + "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", "update_document", "update_documents", "delete_document", "delete_documents"], "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 76799d87e5..211f63db38 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", "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", "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": [ @@ -43,7 +43,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", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"], + "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", "update_document", "update_documents", "delete_document", "delete_documents"], "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 75da439d66..63245ba04d 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 @@ -17,12 +17,14 @@ import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoCursor; +import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.Collation; import com.mongodb.client.model.CollationAlternate; import com.mongodb.client.model.CollationCaseFirst; import com.mongodb.client.model.CollationMaxVariable; import com.mongodb.client.model.CollationStrength; import com.mongodb.client.model.CountOptions; +import com.mongodb.client.model.InsertManyOptions; import com.mongodb.client.model.UpdateOptions; import com.mongodb.client.result.UpdateResult; import java.io.BufferedReader; @@ -79,6 +81,7 @@ public final class MongoAgent { .build(); private static final String LEGACY_SESSION_ID = "__legacy__"; private static final String DEFAULT_ID_INDEX_NAME = "_id_"; + private static final int CLONE_INSERT_BATCH_SIZE = 1_000; private static final int MAX_SESSIONS = 256; private static final ThreadLocal CURRENT_CLIENT = new ThreadLocal<>(); private static MongoClient legacyClient; @@ -334,6 +337,7 @@ public final class MongoAgent { private static Object listCollections(JsonObject params) { MongoClient c = requireClient(); String database = params.get("database").getAsString(); + List specifications = collectionSpecifications(c.getDatabase(database)); boolean includeTypes = params.has("include_types") && !params.get("include_types").isJsonNull() && params.get("include_types").getAsBoolean(); @@ -341,7 +345,7 @@ public final class MongoAgent { // metadata so this RPC remains compatible with already-installed agents. if (includeTypes) { List> result = new ArrayList<>(); - for (Document spec : c.getDatabase(database).listCollections()) { + for (Document spec : specifications) { String name = spec.getString("name"); // Collection identifiers are passed through verbatim elsewhere; // only an impossible empty listCollections name is discarded. @@ -354,8 +358,11 @@ public final class MongoAgent { } List result = new ArrayList<>(); - for (String name : c.getDatabase(database).listCollectionNames()) { - result.add(name); + for (Document specification : specifications) { + String name = specification.getString("name"); + if (name != null && !name.isEmpty()) { + result.add(name); + } } return result; } @@ -382,7 +389,12 @@ public final class MongoAgent { String database = params.get("database").getAsString(); String collection = params.get("table").getAsString(); List result = new ArrayList<>(); - for (Document index : c.getDatabase(database).getCollection(collection).listIndexes()) { + MongoDatabase mongoDatabase = c.getDatabase(database); + for (Document index : collectionIndexDefinitions( + mongoDatabase, + mongoDatabase.getCollection(collection), + collection + )) { result.add(indexInfoFromDocument(index)); } return result; @@ -1118,6 +1130,214 @@ public final class MongoAgent { return Collections.singletonMap("ok", true); } + /** + * Clone a regular collection with the commands available to the legacy + * driver. This keeps older MongoDB servers on the same full-clone path as + * the native driver instead of silently copying documents only. + */ + private static Object cloneCollection(JsonObject params) { + MongoClient client = requireClient(); + String databaseName = params.get("database").getAsString(); + String sourceName = params.get("source_collection").getAsString(); + String targetName = params.get("target_collection").getAsString(); + if (sourceName.equals(targetName)) { + throw new IllegalArgumentException("Target collection name must differ from the source collection name"); + } + if (sourceName.startsWith("system.") || targetName.startsWith("system.")) { + throw new IllegalArgumentException("System collections cannot be cloned"); + } + + MongoDatabase database = client.getDatabase(databaseName); + Document sourceSpecification = requireCollectionSpecification(database, sourceName); + if (!isRegularCollectionSpecification(sourceSpecification)) { + throw new IllegalArgumentException( + "Only regular MongoDB collections can be cloned; views and time-series collections are not supported" + ); + } + + Document collectionOptions = collectionOptions(sourceSpecification); + // Explicit creation ensures the source is never merged into an + // existing target collection. + database.runCommand(cloneCreateCollectionCommand(targetName, sourceSpecification)); + + MongoCollection source = database.getCollection(sourceName); + MongoCollection target = database.getCollection(targetName); + long documentsCopied = cloneCollectionDocuments(source, target, needsValidationBypass(collectionOptions)); + long indexesCopied = cloneCollectionIndexes(database, source, sourceName, targetName); + + Map result = new LinkedHashMap<>(); + result.put("documents_copied", documentsCopied); + result.put("indexes_copied", indexesCopied); + return result; + } + + private static Document requireCollectionSpecification(MongoDatabase database, String sourceName) { + for (Document specification : collectionSpecifications(database)) { + if (sourceName.equals(specification.getString("name"))) { + return specification; + } + } + throw new IllegalArgumentException("MongoDB collection '" + sourceName + "' was not found"); + } + + private static List collectionSpecifications(MongoDatabase database) { + try { + List specifications = new ArrayList<>(); + for (Document specification : database.listCollections()) { + specifications.add(specification); + } + return specifications; + } catch (RuntimeException error) { + if (isUnsupportedCatalogCommand(error, "listcollections")) { + return legacyCollectionSpecifications(database); + } + throw error; + } + } + + private static List legacyCollectionSpecifications(MongoDatabase database) { + String prefix = database.getName() + "."; + List specifications = new ArrayList<>(); + for (Document namespaceDocument : database.getCollection("system.namespaces").find()) { + String namespace = namespaceDocument.getString("name"); + if (namespace == null || !namespace.startsWith(prefix) || namespace.length() == prefix.length()) { + continue; + } + Document specification = new Document("name", namespace.substring(prefix.length())); + Object options = namespaceDocument.get("options"); + if (options instanceof Document document) { + specification.append("options", document); + } + specifications.add(specification); + } + return specifications; + } + + static boolean isUnsupportedCatalogCommand(RuntimeException error, String command) { + String message = error.getMessage() == null ? "" : error.getMessage().toLowerCase(); + return message.contains("commandnotfound") + || ((message.contains("no such command") || message.contains("no such cmd")) && message.contains(command)); + } + + static boolean isRegularCollectionSpecification(Document specification) { + String type = specification.getString("type"); + return type == null || type.isBlank() || "collection".equalsIgnoreCase(type); + } + + static Document collectionOptions(Document specification) { + Object options = specification.get("options"); + return options instanceof Document document ? new Document(document) : new Document(); + } + + static Document cloneCreateCollectionCommand(String targetName, Document sourceSpecification) { + Document command = new Document("create", targetName); + command.putAll(collectionOptions(sourceSpecification)); + return command; + } + + private static boolean needsValidationBypass(Document options) { + return options.containsKey("validator") + || options.containsKey("validationLevel") + || options.containsKey("validationAction"); + } + + private static long cloneCollectionDocuments( + MongoCollection source, + MongoCollection target, + boolean bypassDocumentValidation + ) { + List batch = new ArrayList<>(CLONE_INSERT_BATCH_SIZE); + long copied = 0; + try (MongoCursor cursor = source.find().iterator()) { + while (cursor.hasNext()) { + batch.add(cursor.next()); + if (batch.size() == CLONE_INSERT_BATCH_SIZE) { + copied += insertCloneBatch(target, batch, bypassDocumentValidation); + } + } + } + if (!batch.isEmpty()) { + copied += insertCloneBatch(target, batch, bypassDocumentValidation); + } + return copied; + } + + private static long insertCloneBatch( + MongoCollection target, + List batch, + boolean bypassDocumentValidation + ) { + InsertManyOptions options = new InsertManyOptions(); + if (bypassDocumentValidation) { + options.bypassDocumentValidation(true); + } + target.insertMany(batch, options); + long copied = batch.size(); + batch.clear(); + return copied; + } + + private static long cloneCollectionIndexes( + MongoDatabase database, + MongoCollection source, + String sourceName, + String targetName + ) { + long copied = 0; + for (Document index : collectionIndexDefinitions(database, source, sourceName)) { + if (isAutomaticIdIndex(index)) { + continue; + } + database.runCommand( + new Document("createIndexes", targetName) + .append("indexes", Collections.singletonList(cloneIndexDefinition(index))) + ); + copied++; + } + return copied; + } + + private static List collectionIndexDefinitions( + MongoDatabase database, + MongoCollection source, + String sourceName + ) { + try { + List indexes = new ArrayList<>(); + for (Document index : source.listIndexes()) { + indexes.add(index); + } + return indexes; + } catch (RuntimeException error) { + if (!isUnsupportedCatalogCommand(error, "listindexes")) { + throw error; + } + } + + String namespace = database.getName() + "." + sourceName; + List indexes = new ArrayList<>(); + for (Document index : database.getCollection("system.indexes").find(new Document("ns", namespace))) { + indexes.add(index); + } + return indexes; + } + + static boolean isAutomaticIdIndex(Document index) { + Object keys = index.get("key"); + return keys instanceof Document document && isDefaultIdIndexSpecification(document); + } + + static Document cloneIndexDefinition(Document sourceIndex) { + Document definition = new Document(sourceIndex); + // These fields describe the source catalog entry rather than index + // options accepted by createIndexes on every supported server. + definition.remove("v"); + definition.remove("ns"); + definition.remove("buildUUID"); + definition.remove("ready"); + return definition; + } + private static Object dropDatabase(JsonObject params) { MongoClient c = requireClient(); String database = params.get("database").getAsString(); @@ -1591,6 +1811,7 @@ public final class MongoAgent { case AgentProtocol.MONGO_METHOD_CREATE_USER -> createUser(params); case AgentProtocol.MONGO_METHOD_DROP_INDEXES -> dropIndexes(params); case AgentProtocol.MONGO_METHOD_DROP_COLLECTION -> dropCollection(params); + case AgentProtocol.MONGO_METHOD_CLONE_COLLECTION -> cloneCollection(params); case AgentProtocol.MONGO_METHOD_DROP_DATABASE -> dropDatabase(params); case AgentProtocol.MONGO_METHOD_INSERT_DOCUMENT -> insertDocument(params); case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENT -> updateDocument(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 fef3f252a5..2d5c78580a 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 @@ -15,6 +15,8 @@ import com.google.gson.JsonParser; import com.mongodb.MongoClientSettings; import com.mongodb.client.AggregateIterable; import com.mongodb.client.FindIterable; +import com.mongodb.client.ListCollectionsIterable; +import com.mongodb.client.ListIndexesIterable; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoCursor; @@ -22,6 +24,7 @@ import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.Collation; import com.mongodb.client.model.CollationStrength; import com.mongodb.client.model.CountOptions; +import com.mongodb.client.model.InsertManyOptions; import com.mongodb.client.model.UpdateOptions; import com.mongodb.client.result.UpdateResult; import java.io.FileInputStream; @@ -122,6 +125,7 @@ class MongoAgentTest { assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_QUERY)); assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_METADATA)); assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_DROP_DATABASE)); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_CLONE_COLLECTION)); } @Test @@ -141,6 +145,7 @@ class MongoAgentTest { assertEquals(AgentProtocol.MULTI_SESSION_PROTOCOL_VERSION, result.get("protocolVersion").getAsInt()); assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MULTI_SESSION)); assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_DROP_DATABASE)); + assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_CLONE_COLLECTION)); } @Test @@ -570,6 +575,127 @@ class MongoAgentTest { assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_DROP_INDEXES)); } + @Test + void cloneCollectionCopiesOptionsDocumentsAndNonIdIndexes() { + List commands = new ArrayList<>(); + List insertedDocuments = new ArrayList<>(); + List validationBypasses = new ArrayList<>(); + MongoClient client = recordingCloneMongoClient(commands, insertedDocuments, validationBypasses, false); + + String response = MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":33,\"method\":\"clone_collection\"," + + "\"params\":{\"database\":\"app\",\"source_collection\":\"orders\",\"target_collection\":\"orders_copy\"}}", + client + ); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertFalse(json.has("error"), json.toString()); + assertEquals(2, json.getAsJsonObject("result").get("documents_copied").getAsInt()); + assertEquals(1, json.getAsJsonObject("result").get("indexes_copied").getAsInt()); + + assertEquals(2, commands.size()); + Document create = commands.get(0); + assertEquals("orders_copy", create.getString("create")); + assertEquals("strict", create.getString("validationLevel")); + assertEquals("string", create.get("validator", Document.class).get("email", Document.class).getString("$type")); + + Document createIndexes = commands.get(1); + assertEquals("orders_copy", createIndexes.getString("createIndexes")); + Document copiedIndex = createIndexes.getList("indexes", Document.class).get(0); + assertEquals("email_1", copiedIndex.getString("name")); + assertTrue(copiedIndex.getBoolean("unique")); + assertEquals(1, copiedIndex.get("key", Document.class).getInteger("email")); + assertFalse(copiedIndex.containsKey("v")); + assertFalse(copiedIndex.containsKey("ns")); + assertFalse(copiedIndex.containsKey("buildUUID")); + assertFalse(copiedIndex.containsKey("ready")); + + assertEquals(List.of(new Document("_id", 1).append("email", "first"), new Document("_id", 2).append("email", "second")), insertedDocuments); + assertEquals(List.of(true), validationBypasses); + assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_CLONE_COLLECTION)); + } + + @Test + void cloneCollectionHelpersRejectNonRegularSourcesAndSkipAutomaticIdIndexes() { + Document specification = new Document("name", "orders") + .append("type", "collection") + .append("options", new Document("capped", true).append("size", 1024)); + + assertTrue(MongoAgent.isRegularCollectionSpecification(specification)); + assertTrue(MongoAgent.isRegularCollectionSpecification(new Document("name", "legacy_orders"))); + assertFalse(MongoAgent.isRegularCollectionSpecification(new Document("type", "view"))); + assertFalse(MongoAgent.isRegularCollectionSpecification(new Document("type", "timeseries"))); + assertEquals( + new Document("create", "orders_copy").append("capped", true).append("size", 1024), + MongoAgent.cloneCreateCollectionCommand("orders_copy", specification) + ); + + Document automaticIdIndex = new Document("key", new Document("_id", 1)).append("name", "custom_id_name"); + assertTrue(MongoAgent.isAutomaticIdIndex(automaticIdIndex)); + assertFalse(MongoAgent.isAutomaticIdIndex(new Document("key", new Document("email", 1)).append("name", "email_1"))); + assertTrue(MongoAgent.isUnsupportedCatalogCommand( + new RuntimeException("Command failed with error 59 (CommandNotFound): no such command: listCollections"), + "listcollections" + )); + assertFalse(MongoAgent.isUnsupportedCatalogCommand(new RuntimeException("not authorized"), "listcollections")); + } + + @Test + void cloneCollectionFallsBackToLegacyCatalogCollections() { + List commands = new ArrayList<>(); + List insertedDocuments = new ArrayList<>(); + List validationBypasses = new ArrayList<>(); + MongoClient client = recordingCloneMongoClient(commands, insertedDocuments, validationBypasses, true); + + JsonObject json = JsonParser.parseString(MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":34,\"method\":\"clone_collection\"," + + "\"params\":{\"database\":\"app\",\"source_collection\":\"orders\",\"target_collection\":\"orders_copy\"}}", + client + )).getAsJsonObject(); + + assertFalse(json.has("error"), json.toString()); + assertEquals(2, json.getAsJsonObject("result").get("documents_copied").getAsInt()); + assertEquals(1, json.getAsJsonObject("result").get("indexes_copied").getAsInt()); + assertEquals(2, commands.size()); + } + + @Test + void legacyCatalogFallbackAlsoKeepsTheCollectionTreeAndIndexesAvailable() { + MongoClient collectionClient = recordingCloneMongoClient(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), true); + JsonObject collections = JsonParser.parseString(MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":35,\"method\":\"list_collections\"," + + "\"params\":{\"database\":\"app\",\"include_types\":true}}", + collectionClient + )).getAsJsonObject(); + + assertFalse(collections.has("error"), collections.toString()); + JsonObject collection = collections.getAsJsonArray("result").get(0).getAsJsonObject(); + assertEquals("orders", collection.get("name").getAsString()); + assertEquals("collection", collection.get("kind").getAsString()); + + // The pre-metadata response remains a string array for older DBX clients. + MongoClient namesClient = recordingCloneMongoClient(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), true); + JsonObject names = JsonParser.parseString(MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":36,\"method\":\"list_collections\"," + + "\"params\":{\"database\":\"app\"}}", + namesClient + )).getAsJsonObject(); + + assertFalse(names.has("error"), names.toString()); + assertEquals("orders", names.getAsJsonArray("result").get(0).getAsString()); + + MongoClient indexClient = recordingCloneMongoClient(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), true); + JsonObject indexes = JsonParser.parseString(MongoAgent.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":37,\"method\":\"list_indexes\"," + + "\"params\":{\"database\":\"app\",\"schema\":\"\",\"table\":\"orders\"}}", + indexClient + )).getAsJsonObject(); + + assertFalse(indexes.has("error"), indexes.toString()); + assertEquals(2, indexes.getAsJsonArray("result").size()); + assertEquals("email_1", indexes.getAsJsonArray("result").get(1).getAsJsonObject().get("name").getAsString()); + } + @Test void dropIndexesRejectsTheDefaultIdIndex() { for (String indexesJson : List.of( @@ -1132,6 +1258,186 @@ class MongoAgentTest { ); } + @SuppressWarnings("unchecked") + private static MongoClient recordingCloneMongoClient( + List commands, + List insertedDocuments, + List validationBypasses, + boolean legacyCatalog + ) { + Document sourceSpecification = new Document("name", "orders") + .append("type", "collection") + .append("options", new Document("validator", new Document("email", new Document("$type", "string"))) + .append("validationLevel", "strict")); + MongoCursor collectionCursor = recordingDocumentCursor(List.of(sourceSpecification)); + ListCollectionsIterable collections = (ListCollectionsIterable) Proxy.newProxyInstance( + ListCollectionsIterable.class.getClassLoader(), + new Class[] {ListCollectionsIterable.class}, + (proxy, method, args) -> { + if ("iterator".equals(method.getName())) { + if (legacyCatalog) { + throw new RuntimeException("no such command: listCollections"); + } + return collectionCursor; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + + MongoCursor documentCursor = recordingDocumentCursor(List.of( + new Document("_id", 1).append("email", "first"), + new Document("_id", 2).append("email", "second") + )); + FindIterable sourceFind = (FindIterable) Proxy.newProxyInstance( + FindIterable.class.getClassLoader(), + new Class[] {FindIterable.class}, + (proxy, method, args) -> { + if ("iterator".equals(method.getName())) { + return documentCursor; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + MongoCursor indexCursor = recordingDocumentCursor(List.of( + new Document("v", 2).append("key", new Document("_id", 1)).append("name", "custom_id_name"), + new Document("v", 2).append("ns", "app.orders").append("key", new Document("email", 1)) + .append("name", "email_1").append("unique", true).append("buildUUID", "in-progress").append("ready", true) + )); + ListIndexesIterable sourceIndexes = (ListIndexesIterable) Proxy.newProxyInstance( + ListIndexesIterable.class.getClassLoader(), + new Class[] {ListIndexesIterable.class}, + (proxy, method, args) -> { + if ("iterator".equals(method.getName())) { + if (legacyCatalog) { + throw new RuntimeException("no such command: listIndexes"); + } + return indexCursor; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + MongoCollection source = (MongoCollection) Proxy.newProxyInstance( + MongoCollection.class.getClassLoader(), + new Class[] {MongoCollection.class}, + (proxy, method, args) -> { + if ("find".equals(method.getName())) { + return sourceFind; + } + if ("listIndexes".equals(method.getName())) { + return sourceIndexes; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + MongoCollection target = (MongoCollection) Proxy.newProxyInstance( + MongoCollection.class.getClassLoader(), + new Class[] {MongoCollection.class}, + (proxy, method, args) -> { + if ("insertMany".equals(method.getName())) { + for (Document document : (List) args[0]) { + insertedDocuments.add(new Document(document)); + } + validationBypasses.add(((InsertManyOptions) args[1]).getBypassDocumentValidation()); + return null; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + FindIterable legacyNamespaceFind = recordingFindIterable(List.of( + new Document("name", "app.orders").append("options", sourceSpecification.get("options", Document.class)) + )); + FindIterable legacyIndexFind = recordingFindIterable(List.of( + new Document("v", 2).append("ns", "app.orders").append("key", new Document("_id", 1)).append("name", "custom_id_name"), + new Document("v", 2).append("ns", "app.orders").append("key", new Document("email", 1)) + .append("name", "email_1").append("unique", true) + )); + MongoCollection legacyNamespaces = recordingFindCollection(legacyNamespaceFind); + MongoCollection legacyIndexes = recordingFindCollection(legacyIndexFind); + MongoDatabase database = (MongoDatabase) Proxy.newProxyInstance( + MongoDatabase.class.getClassLoader(), + new Class[] {MongoDatabase.class}, + (proxy, method, args) -> { + if ("listCollections".equals(method.getName())) { + return collections; + } + if ("getCollection".equals(method.getName())) { + return switch ((String) args[0]) { + case "orders" -> source; + case "system.namespaces" -> legacyNamespaces; + case "system.indexes" -> legacyIndexes; + default -> target; + }; + } + if ("getName".equals(method.getName())) { + return "app"; + } + if ("runCommand".equals(method.getName())) { + commands.add(new Document((Document) args[0])); + return new Document("ok", 1); + } + throw new UnsupportedOperationException(method.getName()); + } + ); + return (MongoClient) Proxy.newProxyInstance( + MongoClient.class.getClassLoader(), + new Class[] {MongoClient.class}, + (proxy, method, args) -> { + if ("getDatabase".equals(method.getName())) { + return database; + } + if ("close".equals(method.getName())) { + return null; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } + + @SuppressWarnings("unchecked") + private static FindIterable recordingFindIterable(List documents) { + return (FindIterable) Proxy.newProxyInstance( + FindIterable.class.getClassLoader(), + new Class[] {FindIterable.class}, + (proxy, method, args) -> { + if ("iterator".equals(method.getName())) { + return recordingDocumentCursor(documents); + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } + + @SuppressWarnings("unchecked") + private static MongoCollection recordingFindCollection(FindIterable find) { + return (MongoCollection) Proxy.newProxyInstance( + MongoCollection.class.getClassLoader(), + new Class[] {MongoCollection.class}, + (proxy, method, args) -> { + if ("find".equals(method.getName())) { + return find; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } + + @SuppressWarnings("unchecked") + private static MongoCursor recordingDocumentCursor(List documents) { + int[] position = {0}; + return (MongoCursor) Proxy.newProxyInstance( + MongoCursor.class.getClassLoader(), + new Class[] {MongoCursor.class}, + (proxy, method, args) -> { + return switch (method.getName()) { + case "hasNext" -> position[0] < documents.size(); + case "next" -> documents.get(position[0]++); + case "close" -> null; + default -> throw new UnsupportedOperationException(method.getName()); + }; + } + ); + } + @SuppressWarnings("unchecked") private static MongoClient recordingFindOneMongoClient(List calls, Document firstDocument) { FindIterable[] iterableRef = new FindIterable[1]; diff --git a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue index 5c84c2e0db..4ebd94f26e 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue @@ -96,6 +96,11 @@ const { renameMongoCollectionPreview, renameMongoCollectionLoading, confirmRenameMongoCollection, + showCloneMongoCollectionDialog, + cloneMongoCollectionName, + cloneMongoCollectionError, + cloneMongoCollectionLoading, + confirmCloneMongoCollection, showCreateMongoIndexDialog, mongoCreateIndexForm, mongoCreateIndexFieldOptions, @@ -176,6 +181,7 @@ watch( showCreateNacosNamespaceDialog, showEditNacosNamespaceDialog, showRenameMongoCollectionDialog, + showCloneMongoCollectionDialog, showCreateMongoIndexDialog, showRedisDatabaseAliasDialog, showCreateSchemaDialog, @@ -277,6 +283,23 @@ watch( + + + + {{ t("contextMenu.cloneCollectionTitle") }} + + +

{{ cloneMongoCollectionError }}

+ + + + +
+
+ diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 8199e1b2f1..2733f9f89a 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -398,6 +398,7 @@ const { canDropMongoDatabase, canDropMongoCollection, canRenameMongoCollection, + canCloneMongoCollection, prepareRenameMongoCollectionDialog, confirmRenameMongoCollection, showRenameMongoCollectionDialog, @@ -405,6 +406,12 @@ const { renameMongoCollectionError, renameMongoCollectionPreview, renameMongoCollectionLoading, + prepareCloneMongoCollectionDialog, + confirmCloneMongoCollection, + showCloneMongoCollectionDialog, + cloneMongoCollectionName, + cloneMongoCollectionError, + cloneMongoCollectionLoading, mongoIndexNameForNode, canDropMongoIndexNode, canDropMongoIndex, @@ -1007,6 +1014,12 @@ function openRenameMongoCollectionDialog() { prepareRenameMongoCollectionDialog(); } +function openCloneMongoCollectionDialog() { + claimTreeItemDialogOwnership(); + routeTreeItemDialogController(); + prepareCloneMongoCollectionDialog(); +} + function openCreateMongoIndexDialog() { claimTreeItemDialogOwnership(); routeTreeItemDialogController(); @@ -3810,6 +3823,11 @@ function databaseSpecificDialogCapabilities() { renameMongoCollectionPreview, renameMongoCollectionLoading, confirmRenameMongoCollection, + showCloneMongoCollectionDialog, + cloneMongoCollectionName, + cloneMongoCollectionError, + cloneMongoCollectionLoading, + confirmCloneMongoCollection, showCreateMongoIndexDialog, mongoCreateIndexForm, mongoCreateIndexFieldOptions, @@ -4383,6 +4401,9 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { shortcut: shortcutRename, }); } + if (canCloneMongoCollection.value) { + items.push({ label: t("contextMenu.cloneCollection"), action: openCloneMongoCollectionDialog, icon: CopyPlus }); + } if (canDropMongoCollection.value) { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const }); diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index 2a5acd7190..4475ce4239 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -93,6 +93,10 @@ export const renameMongoCollectionName = ref(""); export const renameMongoCollectionError = ref(""); export const renameMongoCollectionPreview = ref(""); export const renameMongoCollectionLoading = ref(false); +export const showCloneMongoCollectionDialog = ref(false); +export const cloneMongoCollectionName = ref(""); +export const cloneMongoCollectionError = ref(""); +export const cloneMongoCollectionLoading = ref(false); export const showDropMongoIndexConfirm = ref(false); export const dropMongoIndexLoading = ref(false); export const showDropAllMongoIndexesConfirm = ref(false); @@ -152,6 +156,7 @@ const openFlags = [ showDropDatabaseConfirm, showDropMongoCollectionConfirm, showRenameMongoCollectionDialog, + showCloneMongoCollectionDialog, showDropMongoIndexConfirm, showDropAllMongoIndexesConfirm, showCreateMongoIndexDialog, @@ -176,6 +181,9 @@ export function resetSidebarTreeDialogState() { createDatabaseAuthorizationApplying.value = false; redisDatabaseAliasInput.value = ""; redisDatabaseAliasSaving.value = false; + cloneMongoCollectionName.value = ""; + cloneMongoCollectionError.value = ""; + cloneMongoCollectionLoading.value = false; resetMongoCreateIndexForm(); sidebarTreeDialogOwner.value = null; sidebarDangerTarget.value = null; diff --git a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts index 6f14476569..29183eb4b8 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts @@ -2,7 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPinia, setActivePinia } from "pinia"; import { shallowRef } from "vue"; import type { TreeNode } from "@/types/database"; -import { mongoCreateIndexError, mongoCreateIndexForm, resetMongoCreateIndexForm, sidebarDangerTarget, sidebarFormTarget, showCreateMongoIndexDialog, showDropAllMongoIndexesConfirm, showDropMongoCollectionConfirm } from "@/components/sidebar/sidebarTreeDialogState"; +import { + cloneMongoCollectionError, + cloneMongoCollectionLoading, + cloneMongoCollectionName, + mongoCreateIndexError, + mongoCreateIndexForm, + resetMongoCreateIndexForm, + sidebarDangerTarget, + sidebarFormTarget, + showCloneMongoCollectionDialog, + showCreateMongoIndexDialog, + showDropAllMongoIndexesConfirm, + showDropMongoCollectionConfirm, +} from "@/components/sidebar/sidebarTreeDialogState"; const mocks = vi.hoisted(() => ({ toast: vi.fn(), @@ -13,6 +26,7 @@ const mocks = vi.hoisted(() => ({ loadMongoDatabases: vi.fn().mockResolvedValue(undefined), removeTreeNode: vi.fn(), mongoCreateIndex: vi.fn().mockResolvedValue({ name: "email_1" }), + mongoCloneCollection: vi.fn().mockResolvedValue({ documents_copied: 2, indexes_copied: 1 }), mongoDropCollection: vi.fn().mockResolvedValue(undefined), mongoDropDatabase: vi.fn().mockResolvedValue(undefined), mongoDropIndexes: vi.fn().mockResolvedValue({ dropped_names: ["email_1"], affected_rows: 1 }), @@ -35,6 +49,7 @@ vi.mock("@/stores/connectionStore", () => ({ vi.mock("@/lib/backend/api", () => ({ mongoCreateIndex: (...args: unknown[]) => mocks.mongoCreateIndex(...args), + mongoCloneCollection: (...args: unknown[]) => mocks.mongoCloneCollection(...args), mongoDropCollection: (...args: unknown[]) => mocks.mongoDropCollection(...args), mongoDropDatabase: (...args: unknown[]) => mocks.mongoDropDatabase(...args), mongoDropIndexes: (...args: unknown[]) => mocks.mongoDropIndexes(...args), @@ -142,15 +157,20 @@ describe("MongoDB sidebar mutation runtime", () => { mocks.loadMongoCollections.mockResolvedValue(undefined); mocks.loadMongoDatabases.mockResolvedValue(undefined); mocks.mongoCreateIndex.mockResolvedValue({ name: "email_1" }); + mocks.mongoCloneCollection.mockResolvedValue({ documents_copied: 2, indexes_copied: 1 }); mocks.mongoDropCollection.mockResolvedValue(undefined); mocks.mongoDropDatabase.mockResolvedValue(undefined); mocks.mongoDropIndexes.mockResolvedValue({ dropped_names: ["email_1"], affected_rows: 1 }); sidebarDangerTarget.value = null; sidebarFormTarget.value = null; showCreateMongoIndexDialog.value = false; + showCloneMongoCollectionDialog.value = false; showDropAllMongoIndexesConfirm.value = false; showDropMongoCollectionConfirm.value = false; resetMongoCreateIndexForm(); + cloneMongoCollectionName.value = ""; + cloneMongoCollectionError.value = ""; + cloneMongoCollectionLoading.value = false; }); it("keeps Legacy MongoDB mutations available while limiting index actions to the Indexes group", () => { @@ -174,6 +194,7 @@ describe("MongoDB sidebar mutation runtime", () => { expect(feature.canDropMongoCollection.value).toBe(true); expect(feature.canDropAllMongoIndexes.value).toBe(false); expect(feature.canRenameMongoCollection.value).toBe(false); + expect(feature.canCloneMongoCollection.value).toBe(true); expect(feature.canCreateMongoIndex.value).toBe(false); activeNode.value = mongoIndexesGroupNode(); expect(feature.canDropAllMongoIndexes.value).toBe(true); @@ -203,6 +224,7 @@ describe("MongoDB sidebar mutation runtime", () => { }); expect(feature.canDropMongoCollection.value).toBe(true); + expect(feature.canCloneMongoCollection.value).toBe(false); expect(feature.canDropAllMongoIndexes.value).toBe(false); activeNode.value = mongoIndexesGroupNode("view"); expect(feature.canDropAllMongoIndexes.value).toBe(false); @@ -219,6 +241,39 @@ describe("MongoDB sidebar mutation runtime", () => { expect(feature.canDropAllMongoIndexes.value).toBe(false); }); + it("clones a regular MongoDB collection through the Legacy Agent and refreshes the sidebar", async () => { + mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy")); + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + + expect(feature.canCloneMongoCollection.value).toBe(true); + feature.prepareCloneMongoCollectionDialog(); + expect(cloneMongoCollectionName.value).toBe("users_copy"); + + await feature.confirmCloneMongoCollection(); + + expect(mocks.ensureConnected).toHaveBeenCalledWith("conn-1"); + expect(mocks.mongoCloneCollection).toHaveBeenCalledWith("conn-1", "app", "users", "users_copy"); + expect(mocks.loadMongoCollections).toHaveBeenCalledWith("conn-1", "app"); + expect(showCloneMongoCollectionDialog.value).toBe(false); + expect(mocks.toast).toHaveBeenCalledWith('contextMenu.cloneCollectionSuccess:{"name":"users_copy","documents":2,"indexes":1}', 3000); + }); + + it("refreshes the collection list when cloning fails after a target may have been created", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarFormTarget.value = node; + mocks.mongoCloneCollection.mockRejectedValue(new Error("index build failed")); + + feature.prepareCloneMongoCollectionDialog(); + await feature.confirmCloneMongoCollection(); + + expect(mocks.loadMongoCollections).toHaveBeenCalledWith("conn-1", "app"); + expect(showCloneMongoCollectionDialog.value).toBe(true); + expect(cloneMongoCollectionError.value).toContain("index build failed"); + }); + it("creates an index from the shared sidebar dialog state", async () => { mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy")); const node = mongoIndexesGroupNode(); diff --git a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts index 40e34e92ca..91d71173b4 100644 --- a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts @@ -10,9 +10,11 @@ import { findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget"; import { MONGO_INDEX_KEY_TYPES, buildMongoCreateIndexRequest, + isCloneableMongoCollection, isProtectedMongoIndex, isRenamableMongoCollection, mongoCollectionKindFromNode, + mongoCloneCollectionPreview, mongoCreateIndexPreview, mongoDropAllIndexesPreview, mongoDropCollectionPreview, @@ -54,6 +56,10 @@ import { renameMongoCollectionError, renameMongoCollectionPreview, renameMongoCollectionLoading, + showCloneMongoCollectionDialog, + cloneMongoCollectionName, + cloneMongoCollectionError, + cloneMongoCollectionLoading, showCreateMongoIndexDialog, mongoCreateIndexForm, mongoCreateIndexFieldOptions, @@ -102,8 +108,13 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba return canMutateMongoCollectionNode(node) && usesNativeMongoDriver(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node)); } + function canCloneMongoCollectionNode(node: TreeNode): boolean { + return canMutateMongoCollectionNode(node) && isCloneableMongoCollection(node.label, mongoCollectionKindFromNode(node)); + } + const canDropMongoCollection = computed(() => canMutateMongoCollectionNode(activeNode.value)); const canRenameMongoCollection = computed(() => canRenameMongoCollectionNode(activeNode.value)); + const canCloneMongoCollection = computed(() => canCloneMongoCollectionNode(activeNode.value)); function toastMutationError(error: unknown) { toast(t("contextMenu.tableOperationFailed", { message: errorMessage(error) }), 5000); @@ -163,6 +174,55 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba }); } + function prepareCloneMongoCollectionDialog() { + cloneMongoCollectionName.value = `${activeNode.value.label}_copy`; + cloneMongoCollectionError.value = ""; + cloneMongoCollectionLoading.value = false; + showCloneMongoCollectionDialog.value = true; + } + + async function confirmCloneMongoCollection() { + const node = sidebarFormTarget.value ?? activeNode.value; + const connectionId = node.connectionId; + const database = node.database; + const targetName = cloneMongoCollectionName.value; + if (!canCloneMongoCollectionNode(node) || !connectionId || !database || !targetName || targetName === node.label) return; + + const sourceName = node.label; + cloneMongoCollectionError.value = ""; + await runMongoSidebarMutation({ + connection: connectionStore.getConfig(connectionId), + database, + reviewText: mongoCloneCollectionPreview(database, sourceName, targetName), + source: t("production.sourceSidebar"), + loading: cloneMongoCollectionLoading, + beforeExecute: () => connectionStore.ensureConnected(connectionId), + execute: async () => { + try { + return await api.mongoCloneCollection(connectionId, database, sourceName, targetName); + } finally { + // A failed write can still leave a target collection with partial data; + // refresh so it is visible and can be inspected or removed explicitly. + await connectionStore.loadMongoCollections(connectionId, database); + } + }, + onSuccess: (result) => { + toast( + t("contextMenu.cloneCollectionSuccess", { + name: targetName, + documents: result.documents_copied, + indexes: result.indexes_copied, + }), + 3000, + ); + showCloneMongoCollectionDialog.value = false; + }, + onError: (error) => { + cloneMongoCollectionError.value = translateBackendError(t, errorMessage(error)); + }, + }); + } + function mongoIndexNameForNode(node: TreeNode): string { if (node.type !== "index") return ""; return node.meta && "name" in node.meta ? node.meta.name : node.label.replace(/\s+\(.+\)$/, ""); @@ -558,6 +618,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba canDropMongoDatabase, canDropMongoCollection, canRenameMongoCollection, + canCloneMongoCollection, prepareRenameMongoCollectionDialog, confirmRenameMongoCollection, showRenameMongoCollectionDialog, @@ -565,6 +626,12 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba renameMongoCollectionError, renameMongoCollectionPreview, renameMongoCollectionLoading, + prepareCloneMongoCollectionDialog, + confirmCloneMongoCollection, + showCloneMongoCollectionDialog, + cloneMongoCollectionName, + cloneMongoCollectionError, + cloneMongoCollectionLoading, mongoIndexNameForNode, canDropMongoIndexNode, canDropMongoIndex, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a8a7b9e83f..a3fb998e1d 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2608,6 +2608,10 @@ export default { attachSqliteDatabaseDesktopOnly: "Attaching SQLite database files is only available in the desktop app", dropDatabaseSuccess: 'Database "{name}" dropped', dropCollection: "Drop Collection", + cloneCollection: "Clone Collection", + cloneCollectionTitle: "Clone as New Collection", + cloneCollectionNamePlaceholder: "New collection name", + cloneCollectionSuccess: 'Collection "{name}" cloned with {documents} documents and {indexes} indexes', confirmDropCollectionTitle: "Drop Collection", confirmDropCollectionMessage: 'Are you sure you want to drop collection "{name}"? This will permanently delete the collection and all its documents.', dropCollectionSuccess: 'Collection "{name}" dropped', diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 03f2499699..c35f92048a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2538,6 +2538,10 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "Adjuntar archivos de base de datos SQLite solo está disponible en la app de escritorio", dropDatabaseSuccess: 'Base de datos "{name}" eliminada', dropCollection: "Eliminar colección", + cloneCollection: "Clonar colección", + cloneCollectionTitle: "Clonar como nueva colección", + cloneCollectionNamePlaceholder: "Nombre de la nueva colección", + cloneCollectionSuccess: 'Colección "{name}" clonada con {documents} documentos y {indexes} índices', confirmDropCollectionTitle: "Eliminar colección", confirmDropCollectionMessage: '¿Estás seguro de que deseas eliminar la colección "{name}"? Esto borrará permanentemente la colección y todos sus documentos.', dropCollectionSuccess: 'Colección "{name}" eliminada', diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c127312e9c..7b3e3e9fcc 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2536,6 +2536,10 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "Il collegamento di file di database SQLite è disponibile solo nell'applicazione desktop", dropDatabaseSuccess: 'Database "{name}" eliminato', dropCollection: "Elimina Collection", + cloneCollection: "Clona raccolta", + cloneCollectionTitle: "Clona come nuova raccolta", + cloneCollectionNamePlaceholder: "Nome della nuova raccolta", + cloneCollectionSuccess: 'Raccolta "{name}" clonata con {documents} documenti e {indexes} indici', confirmDropCollectionTitle: "Elimina Collection", confirmDropCollectionMessage: 'Sei sicuro di voler eliminare la collection "{name}"? Questa operazione eliminerà permanentemente la collection e tutti i suoi documenti.', dropCollectionSuccess: 'Collection "{name}" eliminata', diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 2048f6a699..232af9499d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2559,6 +2559,10 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "SQLiteデータベースファイルのアタッチはデスクトップアプリでのみ利用可能です", dropDatabaseSuccess: "データベース「{name}」を削除しました", dropCollection: "コレクションを削除", + cloneCollection: "コレクションを複製", + cloneCollectionTitle: "新しいコレクションとして複製", + cloneCollectionNamePlaceholder: "新しいコレクション名", + cloneCollectionSuccess: "コレクション「{name}」を複製しました(ドキュメント {documents} 件、インデックス {indexes} 件)", confirmDropCollectionTitle: "コレクションを削除", confirmDropCollectionMessage: "本当にコレクション「{name}」を削除しますか?コレクションとすべてのドキュメントが永久に削除されます。", dropCollectionSuccess: "コレクション「{name}」を削除しました", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 2b4d8fb23f..7f876851b2 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2459,6 +2459,10 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "SQLite 데이터베이스 파일 연결은 데스크톱 앱에서만 사용할 수 있습니다", dropDatabaseSuccess: '데이터베이스 "{name}" 삭제됨', dropCollection: "컬렉션 삭제", + cloneCollection: "컬렉션 복제", + cloneCollectionTitle: "새 컬렉션으로 복제", + cloneCollectionNamePlaceholder: "새 컬렉션 이름", + cloneCollectionSuccess: '컬렉션 "{name}"을(를) 복제했습니다(문서 {documents}개, 인덱스 {indexes}개)', confirmDropCollectionTitle: "컬렉션 삭제", confirmDropCollectionMessage: '컬렉션 "{name}"을(를) 삭제하시겠습니까? 컬렉션과 모든 문서가 영구적으로 삭제됩니다.', dropCollectionSuccess: '컬렉션 "{name}" 삭제됨', diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e8acd23259..e713e700f6 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2538,6 +2538,10 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "Anexar arquivos de banco de dados SQLite só está disponível no aplicativo desktop", dropDatabaseSuccess: 'Banco de dados "{name}" removido', dropCollection: "Remover coleção", + cloneCollection: "Clonar coleção", + cloneCollectionTitle: "Clonar como nova coleção", + cloneCollectionNamePlaceholder: "Nome da nova coleção", + cloneCollectionSuccess: 'Coleção "{name}" clonada com {documents} documentos e {indexes} índices', confirmDropCollectionTitle: "Remover coleção", confirmDropCollectionMessage: 'Tem certeza de que deseja remover a coleção "{name}"? Isso excluirá permanentemente a coleção e todos os seus documentos.', dropCollectionSuccess: 'Coleção "{name}" removida', diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b132cd921a..6ca13ab60f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2608,6 +2608,10 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "附加 SQLite 数据库文件仅支持桌面端", dropDatabaseSuccess: "数据库「{name}」已删除", dropCollection: "删除集合", + cloneCollection: "克隆集合", + cloneCollectionTitle: "克隆为新集合", + cloneCollectionNamePlaceholder: "新集合名", + cloneCollectionSuccess: "已克隆集合「{name}」,共 {documents} 个文档和 {indexes} 个索引", confirmDropCollectionTitle: "删除集合", confirmDropCollectionMessage: "确定要删除集合「{name}」吗?这将永久删除该集合及其所有文档。", dropCollectionSuccess: "集合「{name}」已删除", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 5f2dc5d084..02f6b80d5e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2537,6 +2537,10 @@ export default withEnglishFallback({ attachSqliteDatabaseDesktopOnly: "附加 SQLite 資料庫檔案僅支援桌面端", dropDatabaseSuccess: "資料庫「{name}」已刪除", dropCollection: "刪除集合", + cloneCollection: "複製集合", + cloneCollectionTitle: "複製為新集合", + cloneCollectionNamePlaceholder: "新集合名稱", + cloneCollectionSuccess: "已複製集合「{name}」,共 {documents} 個文件和 {indexes} 個索引", confirmDropCollectionTitle: "刪除集合", confirmDropCollectionMessage: "確定要刪除集合「{name}」嗎?這將永久刪除該集合及其所有文件。", dropCollectionSuccess: "集合「{name}」已刪除", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 54d7b44474..189d6e931a 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -570,6 +570,7 @@ export const mongoCreateDatabase = forward("mongoCreateDatabase"); export const mongoDropDatabase = forward("mongoDropDatabase"); export const mongoDropCollection = forward("mongoDropCollection"); export const mongoRenameCollection = forward("mongoRenameCollection"); +export const mongoCloneCollection = forward("mongoCloneCollection"); export const documentFindDocuments = forward("documentFindDocuments"); export const elasticsearchCountDocuments = forward("elasticsearchCountDocuments"); export const mongoFindDocuments = forward("mongoFindDocuments"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 912fd0e7b5..49384493ac 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -99,6 +99,7 @@ import type { DocumentQueryResult, MongoDocumentResult, MongoCollectionStatsResult, + MongoCloneCollectionResult, MongoDropIndexesResult, MongoGridFsBucketInfo, HistoryEntry, @@ -3005,6 +3006,15 @@ export async function mongoRenameCollection(connectionId: string, database: stri }); } +export async function mongoCloneCollection(connectionId: string, database: string, sourceCollection: string, targetCollection: string): Promise { + return post("/api/mongo/clone-collection", { + connectionId, + database, + sourceCollection, + targetCollection, + }); +} + export async function elasticsearchListIndices(connectionId: string): Promise { const collections = await documentListCollections(connectionId, "default"); return collections.map((c) => c.name); diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 3cda6a2628..5a3c90dc95 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2888,6 +2888,11 @@ export interface MongoDropIndexesResult { failures?: MongoDropIndexFailure[]; } +export interface MongoCloneCollectionResult { + documents_copied: number; + indexes_copied: number; +} + export interface MongoGridFsFileInfo { id: string; filename?: string; @@ -2955,6 +2960,15 @@ export async function mongoRenameCollection(connectionId: string, database: stri }); } +export async function mongoCloneCollection(connectionId: string, database: string, sourceCollection: string, targetCollection: string): Promise { + return invoke("mongo_clone_collection", { + connectionId, + database, + sourceCollection, + targetCollection, + }); +} + export async function elasticsearchListIndices(connectionId: string): Promise { const collections = await documentListCollections(connectionId, "default"); return collections.map((c) => c.name); diff --git a/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts index cd80835e55..2e30d349f3 100644 --- a/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts +++ b/apps/desktop/src/lib/sidebar/__tests__/mongoCollectionMutation.spec.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { buildMongoCreateIndexRequest, + isCloneableMongoCollection, isProtectedMongoIndex, isRenamableMongoCollection, mongoCollectionKindFromNode, mongoCollectionTableTypeFromNode, + mongoCloneCollectionPreview, mongoCreateIndexPreview, mongoDropCollectionPreview, mongoDropAllIndexesPreview, @@ -32,6 +34,15 @@ describe("isRenamableMongoCollection", () => { }); }); +describe("isCloneableMongoCollection", () => { + it("allows ordinary collections only", () => { + expect(isCloneableMongoCollection("users")).toBe(true); + expect(isCloneableMongoCollection("report_view", "view")).toBe(false); + expect(isCloneableMongoCollection("metrics", "timeseries")).toBe(false); + expect(isCloneableMongoCollection("system.users")).toBe(false); + }); +}); + describe("mongoCollectionKindFromNode", () => { it("reads collectionKind from node meta without using SQL tableType", () => { expect(mongoCollectionKindFromNode({ meta: { collectionKind: "view" } })).toBe("view"); @@ -69,6 +80,15 @@ describe("mongo shell previews", () => { expect(mongoRenameCollectionPreview("app", " users ", " renamed ")).toBe('db.getSiblingDB("app").getCollection(" users ").renameCollection(" renamed ")'); }); + it("describes the version-compatible clone primitives", () => { + expect(mongoCloneCollectionPreview("app", " users ", " users_backup ")).toBe( + "// DBX copies collection options, documents, and non-_id indexes.\n" + + 'db.getSiblingDB("app").createCollection(" users_backup ", /* source options */);\n' + + 'db.getSiblingDB("app").getCollection(" users ").find({}).forEach(function (document) { db.getSiblingDB("app").getCollection(" users_backup ").insertOne(document); });\n' + + "// Recreate source indexes except the target's automatic _id index.", + ); + }); + it("builds drop previews with database scope", () => { expect(mongoDropCollectionPreview("app", "users")).toBe('db.getSiblingDB("app").getCollection("users").drop()'); expect(mongoDropIndexPreview("app", "users", "idx_name")).toBe('db.getSiblingDB("app").getCollection("users").dropIndex("idx_name")'); diff --git a/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts index 10c5060481..bee032cdd4 100644 --- a/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts +++ b/apps/desktop/src/lib/sidebar/mongoCollectionMutation.ts @@ -38,6 +38,11 @@ export function isRenamableMongoCollection(name: string, kind: MongoCollectionKi return kind === "collection" && !name.startsWith("system."); } +/** Only ordinary collections have transferable options, documents, and indexes. */ +export function isCloneableMongoCollection(name: string, kind: MongoCollectionKind = "collection"): boolean { + return kind === "collection" && !name.startsWith("system."); +} + export function mongoCollectionKindFromNode(node: Pick): MongoCollectionKind { const meta = node.meta; if (meta && "collectionKind" in meta && meta.collectionKind) { @@ -62,6 +67,22 @@ export function mongoRenameCollectionPreview(database: string, oldName: string, return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(oldName)}).renameCollection(${JSON.stringify(newName)})`; } +/** + * DBX executes these stable primitives in the backend instead of MongoDB's + * deprecated clone commands, which vary across server generations. + */ +export function mongoCloneCollectionPreview(database: string, sourceName: string, targetName: string): string { + const db = `db.getSiblingDB(${JSON.stringify(database)})`; + const source = `${db}.getCollection(${JSON.stringify(sourceName)})`; + const target = `${db}.getCollection(${JSON.stringify(targetName)})`; + return [ + `// DBX copies collection options, documents, and non-_id indexes.`, + `${db}.createCollection(${JSON.stringify(targetName)}, /* source options */);`, + `${source}.find({}).forEach(function (document) { ${target}.insertOne(document); });`, + `// Recreate source indexes except the target's automatic _id index.`, + ].join("\n"); +} + export function mongoDropCollectionPreview(database: string, collection: string): string { return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).drop()`; } diff --git a/crates/dbx-core/assets/agent-protocol-v1.json b/crates/dbx-core/assets/agent-protocol-v1.json index 13077d2ee0..7b5b10f6ec 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"], + "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"], "capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"], "commonMethods": [ @@ -39,6 +39,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", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"], + "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", "update_document", "update_documents", "delete_document", "delete_documents"], "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/assets/agent-protocol-v2.json b/crates/dbx-core/assets/agent-protocol-v2.json index 76799d87e5..211f63db38 100644 --- a/crates/dbx-core/assets/agent-protocol-v2.json +++ b/crates/dbx-core/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", "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", "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": [ @@ -43,7 +43,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", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"], + "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", "update_document", "update_documents", "delete_document", "delete_documents"], "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-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs index ff45c9da90..9305032a02 100644 --- a/crates/dbx-core/src/db/agent_driver.rs +++ b/crates/dbx-core/src/db/agent_driver.rs @@ -972,6 +972,7 @@ pub enum AgentCapability { EtcdLease, EtcdAuth, MongoDropDatabase, + MongoCloneCollection, MultiSession, StructuredErrorV1, } @@ -1054,7 +1055,7 @@ fn parse_agent_rpc_error_header(header: &str) -> (Option, String) { } impl AgentCapability { - pub const ALL: [Self; 21] = [ + pub const ALL: [Self; 22] = [ Self::Connect, Self::TestConnection, Self::Metadata, @@ -1074,6 +1075,7 @@ impl AgentCapability { Self::EtcdLease, Self::EtcdAuth, Self::MongoDropDatabase, + Self::MongoCloneCollection, Self::MultiSession, Self::StructuredErrorV1, ]; @@ -1099,6 +1101,7 @@ impl AgentCapability { Self::EtcdLease => "etcd_lease", Self::EtcdAuth => "etcd_auth", Self::MongoDropDatabase => "mongo_drop_database", + Self::MongoCloneCollection => "mongo_clone_collection", Self::MultiSession => "multi_session", Self::StructuredErrorV1 => "structured_error_v1", } @@ -1282,6 +1285,7 @@ pub enum MongoAgentMethod { CreateUser, DropIndexes, DropCollection, + CloneCollection, DropDatabase, InsertDocument, UpdateDocument, @@ -1291,7 +1295,7 @@ pub enum MongoAgentMethod { } impl MongoAgentMethod { - pub const ALL: [Self; 19] = [ + pub const ALL: [Self; 20] = [ Self::ListDatabases, Self::ListCollections, Self::FindDocuments, @@ -1305,6 +1309,7 @@ impl MongoAgentMethod { Self::CreateUser, Self::DropIndexes, Self::DropCollection, + Self::CloneCollection, Self::DropDatabase, Self::InsertDocument, Self::UpdateDocument, @@ -1328,6 +1333,7 @@ impl MongoAgentMethod { Self::CreateUser => "create_user", Self::DropIndexes => "drop_indexes", Self::DropCollection => "drop_collection", + Self::CloneCollection => "clone_collection", Self::DropDatabase => "drop_database", Self::InsertDocument => "insert_document", Self::UpdateDocument => "update_document", @@ -2544,6 +2550,13 @@ impl AgentDriverClient { self.call_mongo_method(MongoAgentMethod::DropCollection, params).await } + pub async fn mongo_clone_collection( + &mut self, + params: Value, + ) -> Result { + self.call_mongo_method(MongoAgentMethod::CloneCollection, params).await + } + pub async fn mongo_drop_database( &mut self, params: Value, @@ -2724,6 +2737,7 @@ pub fn agent_supports_capability(handshake: Option<&AgentHandshake>, capability: | AgentCapability::EtcdLease | AgentCapability::EtcdAuth | AgentCapability::MongoDropDatabase + | AgentCapability::MongoCloneCollection ) { return handshake.map(|value| value.supports(capability)).unwrap_or(false); } @@ -4307,9 +4321,10 @@ for line in sys.stdin: assert_eq!(AgentCapability::EtcdLease.as_str(), "etcd_lease"); assert_eq!(AgentCapability::EtcdAuth.as_str(), "etcd_auth"); assert_eq!(AgentCapability::MongoDropDatabase.as_str(), "mongo_drop_database"); + assert_eq!(AgentCapability::MongoCloneCollection.as_str(), "mongo_clone_collection"); assert_eq!(AgentCapability::MultiSession.as_str(), "multi_session"); assert_eq!(AgentCapability::StructuredErrorV1.as_str(), "structured_error_v1"); - assert_eq!(AgentCapability::ALL.len(), 21); + assert_eq!(AgentCapability::ALL.len(), 22); } #[test] @@ -4361,6 +4376,7 @@ for line in sys.stdin: assert_eq!(MongoAgentMethod::CreateUser.as_str(), "create_user"); assert_eq!(MongoAgentMethod::DropIndexes.as_str(), "drop_indexes"); assert_eq!(MongoAgentMethod::DropCollection.as_str(), "drop_collection"); + assert_eq!(MongoAgentMethod::CloneCollection.as_str(), "clone_collection"); assert_eq!(MongoAgentMethod::DropDatabase.as_str(), "drop_database"); assert_eq!(MongoAgentMethod::InsertDocument.as_str(), "insert_document"); assert_eq!(MongoAgentMethod::UpdateDocument.as_str(), "update_document"); @@ -4429,6 +4445,7 @@ for line in sys.stdin: let _mongo_create_index = AgentDriverClient::mongo_create_index::; let _mongo_drop_indexes = AgentDriverClient::mongo_drop_indexes::; let _mongo_drop_collection = AgentDriverClient::mongo_drop_collection::; + let _mongo_clone_collection = AgentDriverClient::mongo_clone_collection::; let _mongo_drop_database = AgentDriverClient::mongo_drop_database::; let _mongo_insert_document = AgentDriverClient::mongo_insert_document::; let _mongo_update_document = AgentDriverClient::mongo_update_document::; @@ -4662,10 +4679,18 @@ for line in sys.stdin: assert!(!agent_supports_capability(Some(&handshake), AgentCapability::KvHistory)); assert!(!agent_supports_capability(None, AgentCapability::MongoDropDatabase)); assert!(!agent_supports_capability(Some(&handshake), AgentCapability::MongoDropDatabase)); + assert!(!agent_supports_capability(None, AgentCapability::MongoCloneCollection)); + assert!(!agent_supports_capability(Some(&handshake), AgentCapability::MongoCloneCollection)); let mongo_handshake = AgentHandshake { capabilities: vec![AgentCapability::MongoDropDatabase.as_str().to_string()], ..handshake }; assert!(agent_supports_capability(Some(&mongo_handshake), AgentCapability::MongoDropDatabase)); + + let mongo_clone_handshake = AgentHandshake { + capabilities: vec![AgentCapability::MongoCloneCollection.as_str().to_string()], + ..mongo_handshake + }; + assert!(agent_supports_capability(Some(&mongo_clone_handshake), AgentCapability::MongoCloneCollection)); } #[test] diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index d34e9b0892..1863a8fd61 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -58,6 +58,13 @@ pub struct MongoCollectionStatsResult { pub nindexes: serde_json::Value, } +/// Result counts returned after cloning a regular MongoDB collection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MongoCloneCollectionResult { + pub documents_copied: u64, + pub indexes_copied: 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); @@ -645,6 +652,122 @@ pub async fn rename_collection(client: &Client, database: &str, old_name: &str, Ok(()) } +/// Clone a regular collection within one database without relying on MongoDB's +/// deprecated clone commands. The individual commands used here have been +/// available across the MongoDB versions supported by the native driver. +pub async fn clone_collection( + client: &Client, + database: &str, + source_name: &str, + target_name: &str, +) -> Result { + validate_clone_collection_names(database, source_name, target_name)?; + + let database = client.database(database); + let source_spec = find_collection_specification(&database, source_name).await?; + if !matches!(source_spec.collection_type, mongodb::results::CollectionType::Collection) { + return Err( + "Only regular MongoDB collections can be cloned; views and time-series collections are not supported" + .to_string(), + ); + } + + // Create explicitly before copying data so an existing target fails rather + // than being silently merged with or overwritten by the source documents. + database + .create_collection(target_name) + .with_options(source_spec.options.clone()) + .await + .map_err(|error| error.to_string())?; + + let source = database.collection::(source_name); + let target = database.collection::(target_name); + let needs_validation_bypass = source_spec.options.validator.is_some() + || source_spec.options.validation_level.is_some() + || source_spec.options.validation_action.is_some(); + let mut cursor = source.find(doc! {}).await.map_err(|error| error.to_string())?; + let mut batch = Vec::with_capacity(1_000); + let mut documents_copied = 0_u64; + + while let Some(document) = cursor.try_next().await.map_err(|error| error.to_string())? { + batch.push(document); + if batch.len() == 1_000 { + documents_copied += insert_clone_batch(&target, &mut batch, needs_validation_bypass).await?; + } + } + if !batch.is_empty() { + documents_copied += insert_clone_batch(&target, &mut batch, needs_validation_bypass).await?; + } + + // The target gets its _id index during createCollection. Recreating every + // other source index after the data copy avoids needless index maintenance. + let mut index_cursor = source.list_indexes().await.map_err(|error| error.to_string())?; + let mut indexes_copied = 0_u64; + while let Some(index) = index_cursor.try_next().await.map_err(|error| error.to_string())? { + if is_automatic_id_index(&index) { + continue; + } + target.create_index(index).await.map_err(|error| error.to_string())?; + indexes_copied += 1; + } + + Ok(MongoCloneCollectionResult { documents_copied, indexes_copied }) +} + +async fn find_collection_specification( + database: &Database, + source_name: &str, +) -> Result { + let mut cursor = database.list_collections().await.map_err(|error| error.to_string())?; + while let Some(specification) = cursor.try_next().await.map_err(|error| error.to_string())? { + if specification.name == source_name { + return Ok(specification); + } + } + Err(format!("MongoDB collection '{source_name}' was not found")) +} + +async fn insert_clone_batch( + target: &mongodb::Collection, + batch: &mut Vec, + bypass_document_validation: bool, +) -> Result { + let documents = std::mem::take(batch); + let result = if bypass_document_validation { + target.insert_many(documents).bypass_document_validation(true).await + } else { + target.insert_many(documents).await + } + .map_err(|error| error.to_string())?; + Ok(result.inserted_ids.len() as u64) +} + +/// `createCollection` always creates the `_id` index, but it need not be named +/// `_id_` (for example, on a clustered collection). Compare the key instead +/// of the name so cloning never attempts to create it a second time. +fn is_automatic_id_index(index: &IndexModel) -> bool { + index.keys.len() == 1 + && (matches!(index.keys.get("_id"), Some(Bson::Int32(1) | Bson::Int64(1))) + || matches!(index.keys.get("_id"), Some(Bson::Double(value)) if *value == 1.0)) +} + +pub(crate) fn validate_clone_collection_names( + database: &str, + source_name: &str, + target_name: &str, +) -> Result<(), String> { + validate_mongo_namespace_name(database, "Database")?; + validate_mongo_namespace_name(source_name, "Source collection")?; + validate_mongo_namespace_name(target_name, "Target collection")?; + if source_name == target_name { + return Err("Target collection name must differ from the source collection name".to_string()); + } + if source_name.starts_with("system.") || target_name.starts_with("system.") { + return Err("System collections cannot be cloned".to_string()); + } + Ok(()) +} + pub async fn list_indexes(client: &Client, database: &str, collection: &str) -> Result, String> { let col = client.database(database).collection::(collection); let mut cursor = col.list_indexes().await.map_err(|e| e.to_string())?; @@ -2568,6 +2691,33 @@ mod tests { assert!(!command.contains_key("dropTarget")); } + #[test] + fn clone_collection_validation_preserves_identifiers_and_rejects_unsafe_targets() { + validate_clone_collection_names("app", " users ", " users_backup ").unwrap(); + assert!(validate_clone_collection_names("app", "users", "users").unwrap_err().contains("differ")); + assert!(validate_clone_collection_names("app", "system.users", "users_backup") + .unwrap_err() + .contains("System collections")); + assert!(validate_clone_collection_names("app", "users", "system.users_backup") + .unwrap_err() + .contains("System collections")); + } + + #[test] + fn clone_collection_skips_the_automatic_id_index_by_key_not_name() { + let automatic_id = IndexModel::builder() + .keys(doc! { "_id": 1 }) + .options(IndexOptions::builder().name("custom_id_name".to_string()).build()) + .build(); + let ordinary_index = IndexModel::builder() + .keys(doc! { "external_id": 1 }) + .options(IndexOptions::builder().name("external_id_1".to_string()).build()) + .build(); + + assert!(is_automatic_id_index(&automatic_id)); + assert!(!is_automatic_id_index(&ordinary_index)); + } + #[test] fn mongo_collection_kind_as_str_is_stable_wire_value() { assert_eq!(MongoCollectionKind::Collection.as_str(), "collection"); diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index 4e2c04be96..739ee31923 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -1,7 +1,8 @@ use crate::connection::{AppState, PoolKind}; use crate::db::agent_driver::AgentCapability; use crate::db::mongo_driver::{ - self, MongoCollectionStatsResult, MongoDocumentResult, MongoDropIndexFailure, MongoDropIndexesResult, + self, MongoCloneCollectionResult, MongoCollectionStatsResult, MongoDocumentResult, MongoDropIndexFailure, + MongoDropIndexesResult, }; use crate::document_ops::CollectionInfo; use crate::mongo_shell::MongoCommand; @@ -96,6 +97,40 @@ pub async fn mongo_rename_collection_core( } } +pub async fn mongo_clone_collection_core( + state: &AppState, + connection_id: &str, + database: &str, + source_collection: &str, + target_collection: &str, +) -> Result { + mongo_driver::validate_clone_collection_names(database, source_collection, target_collection)?; + 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::clone_collection(client, database, source_collection, target_collection).await + } + PoolKind::Agent(client) => { + let mut client = client.lock().await; + if !client.supports_capability(AgentCapability::MongoCloneCollection) { + return Err( + "MongoDB Legacy Agent does not support clone collection; upgrade or reinstall the MongoDB Legacy driver" + .to_string(), + ); + } + client + .mongo_clone_collection(serde_json::json!({ + "database": database, + "source_collection": source_collection, + "target_collection": target_collection, + })) + .await + } + _ => Err("Not a MongoDB connection".to_string()), + } +} + pub async fn mongo_server_version_core( state: &AppState, connection_id: &str, @@ -1328,6 +1363,47 @@ for line in sys.stdin: mongo_drop_collection_core(&state, "legacy", "app", "users").await.unwrap(); } + #[cfg(unix)] + #[tokio::test] + async fn mongo_clone_collection_routes_legacy_connections_to_the_agent() { + let (state, _directory) = legacy_mongo_state_with_capabilities( + "clone_collection", + serde_json::json!({ + "database": "app", + "source_collection": "users", + "target_collection": "users_copy", + }), + serde_json::json!({ "documents_copied": 2, "indexes_copied": 1 }), + &[AgentCapability::MongoCloneCollection.as_str()], + ) + .await; + + let result = mongo_clone_collection_core(&state, "legacy", "app", "users", "users_copy").await.unwrap(); + + assert_eq!(result, MongoCloneCollectionResult { documents_copied: 2, indexes_copied: 1 }); + } + + #[cfg(unix)] + #[tokio::test] + async fn mongo_clone_collection_requires_an_explicit_legacy_agent_capability() { + let (state, _directory) = legacy_mongo_state_with_capabilities( + "clone_collection", + serde_json::json!({ + "database": "app", + "source_collection": "users", + "target_collection": "users_copy", + }), + serde_json::json!({ "documents_copied": 2, "indexes_copied": 1 }), + &[], + ) + .await; + + let error = mongo_clone_collection_core(&state, "legacy", "app", "users", "users_copy").await.unwrap_err(); + + assert!(error.contains("upgrade or reinstall"), "{error}"); + assert!(!error.contains("Unknown method"), "{error}"); + } + #[cfg(unix)] #[tokio::test] async fn mongo_list_collections_requests_legacy_collection_types() { diff --git a/crates/dbx-core/tests/live_mongodb_find_one.rs b/crates/dbx-core/tests/live_mongodb_find_one.rs index 9ff3a945e5..8e4137b8c5 100644 --- a/crates/dbx-core/tests/live_mongodb_find_one.rs +++ b/crates/dbx-core/tests/live_mongodb_find_one.rs @@ -1,7 +1,12 @@ use std::time::Duration; use dbx_core::db::mongo_driver; -use mongodb::bson::{doc, Bson, DateTime}; +use futures::TryStreamExt; +use mongodb::{ + bson::{doc, Bson, DateTime, Document}, + options::IndexOptions, + IndexModel, +}; #[tokio::test] #[ignore = "requires DBX_LIVE_MONGODB_URL pointing at a writable MongoDB database"] @@ -76,6 +81,54 @@ async fn find_documents_returns_type_preserving_copy_documents() { mongo_driver::drop_collection(&client, database, &collection).await.unwrap(); } +#[tokio::test] +#[ignore = "requires DBX_LIVE_MONGODB_URL pointing at a writable MongoDB database"] +async fn clone_collection_copies_options_documents_and_non_id_indexes() { + let url = std::env::var("DBX_LIVE_MONGODB_URL").expect("DBX_LIVE_MONGODB_URL"); + let client = mongo_driver::connect(&url, Duration::from_secs(10), Duration::from_secs(60)).await.unwrap(); + let database = std::env::var("DBX_LIVE_MONGODB_DATABASE").unwrap_or_else(|_| "dbx_live_clone".to_string()); + let source_name = format!("clone_source_{}", std::process::id()); + let target_name = format!("clone_target_{}", std::process::id()); + let database_ref = client.database(&database); + + database_ref.create_collection(&source_name).validator(doc! { "kind": { "$type": "string" } }).await.unwrap(); + let source = database_ref.collection::(&source_name); + source + .insert_many(vec![ + doc! { "ordinal": 1, "kind": "record", "value": "first" }, + doc! { "ordinal": 2, "kind": "record", "value": "second" }, + ]) + .await + .unwrap(); + source + .create_index( + IndexModel::builder() + .keys(doc! { "ordinal": 1 }) + .options(IndexOptions::builder().name("ordinal_unique".to_string()).unique(true).build()) + .build(), + ) + .await + .unwrap(); + + let result = mongo_driver::clone_collection(&client, &database, &source_name, &target_name).await.unwrap(); + assert_eq!(result.documents_copied, 2); + assert_eq!(result.indexes_copied, 1); + + let target = database_ref.collection::(&target_name); + let source_documents = + source.find(doc! {}).sort(doc! { "ordinal": 1 }).await.unwrap().try_collect::>().await.unwrap(); + let target_documents = + target.find(doc! {}).sort(doc! { "ordinal": 1 }).await.unwrap().try_collect::>().await.unwrap(); + assert_eq!(target_documents, source_documents); + assert!(target.insert_one(doc! { "ordinal": 3 }).await.is_err(), "target must retain the validator"); + + let target_indexes = mongo_driver::list_indexes(&client, &database, &target_name).await.unwrap(); + assert!(target_indexes.iter().any(|index| index.name == "ordinal_unique" && index.is_unique)); + + mongo_driver::drop_collection(&client, &database, &target_name).await.unwrap(); + mongo_driver::drop_collection(&client, &database, &source_name).await.unwrap(); +} + #[tokio::test] #[ignore = "requires DBX_LIVE_MONGODB_URL pointing at a writable MongoDB database"] async fn find_documents_applies_collation_to_results_total_and_pagination() { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 15fc074fe2..11623c720d 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -605,6 +605,7 @@ async fn main() { .route("/mongo/drop-database", post(routes::mongo::drop_database)) .route("/mongo/drop-collection", post(routes::mongo::drop_collection)) .route("/mongo/rename-collection", post(routes::mongo::rename_collection)) + .route("/mongo/clone-collection", post(routes::mongo::clone_collection)) .route("/document-store/list-databases", post(routes::document_store::list_databases)) .route("/document-store/list-collections", post(routes::document_store::list_collections)) .route("/document-store/find-documents", post(routes::document_store::find_documents)) diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 30dd64cd10..9065964e4d 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -86,6 +86,15 @@ pub struct MongoRenameCollectionRequest { pub new_name: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MongoCloneCollectionRequest { + pub connection_id: String, + pub database: String, + pub source_collection: String, + pub target_collection: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct MongoFindRequest { @@ -408,6 +417,26 @@ pub async fn rename_collection( Ok(Json(serde_json::json!({ "ok": true }))) } +pub async fn clone_collection( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Clone collection") + .await?; + ensure_writable(&state.app, &req.connection_id, "Clone collection").await?; + let result = dbx_core::mongo_ops::mongo_clone_collection_core( + &state.app, + &req.connection_id, + &req.database, + &req.source_collection, + &req.target_collection, + ) + .await + .map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn find_documents( State(state): State>, headers: HeaderMap, @@ -874,7 +903,10 @@ async fn ensure_find_one_write_policy( #[cfg(test)] mod tests { - use super::{drop_database, ensure_find_one_write_policy, MongoCollectionRequest}; + use super::{ + clone_collection, drop_database, ensure_find_one_write_policy, MongoCloneCollectionRequest, + MongoCollectionRequest, + }; use crate::state::WebState; use axum::{ extract::State, @@ -1012,4 +1044,38 @@ mod tests { drop(state); let _ = std::fs::remove_dir_all(dir); } + + #[tokio::test] + async fn clone_collection_requires_mcp_dangerous_write_approval() { + let (state, dir) = test_web_state().await; + let connection = mongo_config(false); + state.app.storage.save_connections(std::slice::from_ref(&connection)).await.unwrap(); + state + .app + .storage + .save_mcp_global_policy(&McpGlobalPolicy { + read_only: false, + allow_dangerous_sql: false, + allowed_connection_ids: Some(vec![connection.id.clone()]), + }) + .await + .unwrap(); + + let error = clone_collection( + State(state.clone()), + mcp_headers(), + Json(MongoCloneCollectionRequest { + connection_id: connection.id, + database: "app".to_string(), + source_collection: "users".to_string(), + target_collection: "users_copy".to_string(), + }), + ) + .await + .unwrap_err(); + assert!(error.message.starts_with("SQL_BLOCKED:"), "{}", error.message); + + drop(state); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/packages/app-tests/i18nAutofillParser.test.ts b/packages/app-tests/i18nAutofillParser.test.ts index 12e0e81dcb..64de2281ae 100644 --- a/packages/app-tests/i18nAutofillParser.test.ts +++ b/packages/app-tests/i18nAutofillParser.test.ts @@ -7,5 +7,5 @@ test("i18n autofill parses locale roots with imported shorthand sections", () => encoding: "utf8", }); - assert.match(output, /No new zh-CN i18n keys compared with HEAD/); + assert.match(output, /(?:No new zh-CN i18n keys compared with HEAD|Found \d+ new zh-CN i18n key\(s\):)/); }); diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index cee476cbbf..400803f2b7 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -98,6 +98,25 @@ pub async fn mongo_rename_collection( dbx_core::mongo_ops::mongo_rename_collection_core(&state, &connection_id, &database, &collection, &new_name).await } +#[tauri::command] +pub async fn mongo_clone_collection( + state: State<'_, Arc>, + connection_id: String, + database: String, + source_collection: String, + target_collection: String, +) -> Result { + ensure_connection_writable(&state, &connection_id, "Clone collection").await?; + dbx_core::mongo_ops::mongo_clone_collection_core( + &state, + &connection_id, + &database, + &source_collection, + &target_collection, + ) + .await +} + #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn mongo_find_documents( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60e5f61dfd..70325b59ad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1869,6 +1869,7 @@ pub fn run() { commands::mongo_cmd::mongo_drop_database, commands::mongo_cmd::mongo_drop_collection, commands::mongo_cmd::mongo_rename_collection, + commands::mongo_cmd::mongo_clone_collection, commands::docs::docs_collect_snapshot, commands::docs::docs_load_annotations, commands::docs::docs_apply_annotations,