feat(mongodb): improve sidebar index management

This commit is contained in:
onenewcode
2026-08-02 22:45:33 +08:00
committed by GitHub
parent 22055e081f
commit 8e7b74d0ee
56 changed files with 3558 additions and 246 deletions
@@ -57,6 +57,7 @@ public final class AgentProtocol {
public static final String MONGO_METHOD_CREATE_INDEX = "create_index";
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_DROP_DATABASE = "drop_database";
public static final String MONGO_METHOD_INSERT_DOCUMENT = "insert_document";
public static final String MONGO_METHOD_UPDATE_DOCUMENT = "update_document";
public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents";
@@ -112,6 +113,7 @@ public final class AgentProtocol {
public static final String CAPABILITY_ETCD_WATCH = "etcd_watch";
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_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
@@ -143,11 +145,14 @@ public final class AgentProtocol {
CAPABILITY_ETCD_DEFRAG,
CAPABILITY_ETCD_WATCH,
CAPABILITY_ETCD_LEASE,
CAPABILITY_ETCD_AUTH
CAPABILITY_ETCD_AUTH,
CAPABILITY_MONGO_DROP_DATABASE
));
public static final List<String> MULTI_SESSION_CAPABILITIES;
public static final List<String> MULTI_SESSION_ALL_CAPABILITIES;
public static final List<String> MONGO_LEGACY_CAPABILITIES;
public static final List<String> MONGO_LEGACY_MULTI_SESSION_CAPABILITIES;
public static final List<String> MULTI_SESSION_JDBC_CAPABILITIES;
public static final List<String> MULTI_SESSION_JDBC_ALL_CAPABILITIES;
@@ -197,6 +202,14 @@ public final class AgentProtocol {
allCapabilities.add(CAPABILITY_MULTI_SESSION);
MULTI_SESSION_ALL_CAPABILITIES = Collections.unmodifiableList(allCapabilities);
List<String> mongoCapabilities = new java.util.ArrayList<>(CAPABILITIES);
mongoCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities);
List<String> mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities);
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
jdbcCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
MULTI_SESSION_JDBC_CAPABILITIES = Collections.unmodifiableList(jdbcCapabilities);
@@ -226,6 +239,7 @@ public final class AgentProtocol {
MONGO_METHOD_CREATE_INDEX,
MONGO_METHOD_DROP_INDEXES,
MONGO_METHOD_DROP_COLLECTION,
MONGO_METHOD_DROP_DATABASE,
MONGO_METHOD_INSERT_DOCUMENT,
MONGO_METHOD_UPDATE_DOCUMENT,
MONGO_METHOD_UPDATE_DOCUMENTS,
@@ -281,6 +295,18 @@ public final class AgentProtocol {
);
}
public static HandshakeResult mongoLegacyHandshakeResult() {
return new HandshakeResult(PROTOCOL_VERSION, PROTOCOL_VERSION, MONGO_LEGACY_CAPABILITIES);
}
public static HandshakeResult mongoLegacyMultiSessionHandshakeResult() {
return new HandshakeResult(
MULTI_SESSION_PROTOCOL_VERSION,
MULTI_SESSION_PROTOCOL_VERSION,
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES
);
}
/**
* Handshake for pooled JDBC Agents that emit the v1 structured error contract.
* Generic/custom v2 handlers must continue using multiSessionHandshakeResult().
@@ -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"],
"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"],
"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_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_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"]
}
@@ -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", "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", "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_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_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"
@@ -21,6 +21,7 @@ import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -48,6 +49,7 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
@@ -56,6 +58,7 @@ import javax.net.ssl.TrustManagerFactory;
import org.bson.Document;
import org.bson.json.JsonMode;
import org.bson.json.JsonWriterSettings;
import org.bson.types.Decimal128;
import org.bson.types.ObjectId;
public final class MongoAgent {
@@ -66,6 +69,7 @@ public final class MongoAgent {
.outputMode(JsonMode.RELAXED)
.build();
private static final String LEGACY_SESSION_ID = "__legacy__";
private static final String DEFAULT_ID_INDEX_NAME = "_id_";
private static final int MAX_SESSIONS = 256;
private static final ThreadLocal<MongoClient> CURRENT_CLIENT = new ThreadLocal<>();
private static MongoClient legacyClient;
@@ -321,6 +325,25 @@ public final class MongoAgent {
private static Object listCollections(JsonObject params) {
MongoClient c = requireClient();
String database = params.get("database").getAsString();
boolean includeTypes = params.has("include_types")
&& !params.get("include_types").isJsonNull()
&& params.get("include_types").getAsBoolean();
// Older DBX clients expect a simple string array. Opt into collection
// metadata so this RPC remains compatible with already-installed agents.
if (includeTypes) {
List<Map<String, String>> result = new ArrayList<>();
for (Document spec : c.getDatabase(database).listCollections()) {
String name = spec.getString("name");
// Collection identifiers are passed through verbatim elsewhere;
// only an impossible empty listCollections name is discarded.
if (name == null || name.isEmpty()) {
continue;
}
result.add(collectionSpec(name, spec.getString("type")));
}
return result;
}
List<String> result = new ArrayList<>();
for (String name : c.getDatabase(database).listCollectionNames()) {
result.add(name);
@@ -328,6 +351,23 @@ public final class MongoAgent {
return result;
}
static Map<String, String> collectionSpec(String name, String type) {
Map<String, String> result = new LinkedHashMap<>();
result.put("name", name);
result.put("kind", collectionKind(type));
return result;
}
static String collectionKind(String type) {
if ("view".equalsIgnoreCase(type)) {
return "view";
}
if ("timeseries".equalsIgnoreCase(type)) {
return "timeseries";
}
return "collection";
}
private static Object listIndexes(JsonObject params) {
MongoClient c = requireClient();
String database = params.get("database").getAsString();
@@ -497,6 +537,37 @@ public final class MongoAgent {
return version;
}
static boolean serverVersionRequiresSerialDropIndexes(String version) {
if (version == null) {
return false;
}
int start = 0;
while (start < version.length() && !Character.isDigit(version.charAt(start))) {
start++;
}
String[] components = version.substring(start).split("\\.", 3);
if (components.length < 2) {
return false;
}
try {
int major = Integer.parseInt(components[0]);
int minor = Integer.parseInt(components[1]);
return major < 4 || (major == 4 && minor < 2);
} catch (NumberFormatException error) {
return false;
}
}
private static boolean serverRequiresSerialDropIndexes(MongoClient client, String database) {
try {
Document buildInfo = client.getDatabase(database).runCommand(new Document("buildInfo", 1));
return serverVersionRequiresSerialDropIndexes(serverVersionFromBuildInfo(buildInfo));
} catch (RuntimeException error) {
// Without an explicit old version, preserve MongoDB's single-command array semantics.
return false;
}
}
private static Object createIndex(JsonObject params) {
MongoClient c = requireClient();
String database = params.get("database").getAsString();
@@ -509,12 +580,22 @@ public final class MongoAgent {
Document index = new Document("key", keys);
Document options = documentOrNull(params, "options_json");
if (options != null) {
if (options.containsKey("key")) {
throw new IllegalArgumentException("Index options cannot contain \"key\"; specify index fields in keys JSON");
}
index.putAll(options);
}
String name = index.getString("name");
if (name == null || name.isBlank()) {
String name;
if (!index.containsKey("name")) {
name = defaultIndexName(keys);
index.put("name", name);
} else if (!(index.get("name") instanceof String)) {
throw new IllegalArgumentException("Index option \"name\" must be a non-empty string");
} else {
name = (String) index.get("name");
if (name.isBlank()) {
throw new IllegalArgumentException("Index option \"name\" must be a non-empty string");
}
}
c.getDatabase(database).runCommand(
@@ -532,14 +613,24 @@ public final class MongoAgent {
return document;
}
private static String defaultIndexName(Document keys) {
static String defaultIndexName(Document keys) {
List<String> parts = new ArrayList<>();
for (Map.Entry<String, Object> entry : keys.entrySet()) {
parts.add(entry.getKey() + "_" + String.valueOf(entry.getValue()));
parts.add(entry.getKey() + "_" + defaultIndexNameValue(entry.getValue()));
}
return String.join("_", parts);
}
private static String defaultIndexNameValue(Object value) {
if (value instanceof Double number && Double.isFinite(number)) {
// The Rust driver's BSON formatter omits a fractional suffix for
// whole doubles (for example, 1.0 becomes 1). Keep unnamed index
// names identical on Native and Legacy connections.
return BigDecimal.valueOf(number).stripTrailingZeros().toPlainString();
}
return String.valueOf(value);
}
private static Object dropIndexes(JsonObject params) {
MongoClient c = requireClient();
String database = params.get("database").getAsString();
@@ -548,13 +639,50 @@ public final class MongoAgent {
boolean single = params.has("single") && !params.get("single").isJsonNull() && params.get("single").getAsBoolean();
Object index = parseDropIndexesValue(indexesJson, single);
if (index instanceof List<?> indexes && serverRequiresSerialDropIndexes(c, database)) {
// Array-form dropIndexes was added in MongoDB 4.2. Older servers
// require individual commands and therefore return partial results.
return dropNamedIndexes(
indexes,
indexName -> c.getDatabase(database).runCommand(
new Document("dropIndexes", collection).append("index", indexName)
)
);
}
List<IndexInfo> before = listIndexInfos(c, database, collection);
c.getDatabase(database).runCommand(new Document("dropIndexes", collection).append("index", index));
List<IndexInfo> after = listIndexInfos(c, database, collection);
List<String> droppedNames = diffDroppedIndexNames(before, after);
return dropIndexesResult(droppedNames, Collections.emptyList());
}
static Map<String, Object> dropNamedIndexes(List<?> indexes, Consumer<Object> dropCommand) {
List<String> droppedNames = new ArrayList<>();
List<Map<String, String>> failures = new ArrayList<>();
for (Object indexName : indexes) {
String name = String.valueOf(indexName);
try {
dropCommand.accept(indexName);
droppedNames.add(name);
} catch (RuntimeException error) {
String message = error.getMessage() == null ? error.toString() : error.getMessage();
failures.add(Map.of("name", name, "message", message));
}
}
return dropIndexesResult(droppedNames, failures);
}
private static Map<String, Object> dropIndexesResult(
List<String> droppedNames,
List<Map<String, String>> failures
) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("dropped_names", droppedNames);
result.put("affected_rows", droppedNames.size());
if (!failures.isEmpty()) {
result.put("failures", failures);
}
return result;
}
@@ -566,7 +694,14 @@ public final class MongoAgent {
return Collections.singletonMap("ok", true);
}
private static Object parseDropIndexesValue(String indexesJson, boolean single) {
private static Object dropDatabase(JsonObject params) {
MongoClient c = requireClient();
String database = params.get("database").getAsString();
c.getDatabase(database).drop();
return Collections.singletonMap("ok", true);
}
static Object parseDropIndexesValue(String indexesJson, boolean single) {
if (indexesJson == null || indexesJson.isBlank()) {
if (single) {
throw new IllegalArgumentException("dropIndex requires a string index name or JSON document");
@@ -583,6 +718,9 @@ public final class MongoAgent {
if (single && "*".equals(name)) {
throw new IllegalArgumentException("dropIndex does not accept \"*\"; use dropIndexes() or dropIndexes(\"*\") instead");
}
if (DEFAULT_ID_INDEX_NAME.equals(name)) {
throw new IllegalArgumentException("The default MongoDB _id_ index cannot be dropped");
}
return name;
}
if (value.isJsonObject()) {
@@ -590,7 +728,11 @@ public final class MongoAgent {
if (object.size() == 0) {
throw new IllegalArgumentException("Index specification is required");
}
return Document.parse(indexesJson);
Document specification = Document.parse(indexesJson);
if (isDefaultIdIndexSpecification(specification)) {
throw new IllegalArgumentException("The default MongoDB _id_ index cannot be dropped");
}
return specification;
}
if (value.isJsonArray()) {
if (single) {
@@ -606,6 +748,9 @@ public final class MongoAgent {
if (names.isEmpty()) {
throw new IllegalArgumentException("dropIndexes only accepts non-empty string arrays");
}
if (names.contains(DEFAULT_ID_INDEX_NAME)) {
throw new IllegalArgumentException("The default MongoDB _id_ index cannot be dropped");
}
return names;
}
if (single) {
@@ -614,6 +759,20 @@ public final class MongoAgent {
throw new IllegalArgumentException("dropIndexes only accepts a string index name, JSON document, or string array");
}
private static boolean isDefaultIdIndexSpecification(Document specification) {
if (specification.size() != 1 || !specification.containsKey("_id")) {
return false;
}
Object direction = specification.get("_id");
if (direction instanceof Number number) {
return number.doubleValue() == 1.0;
}
// Document.parse turns Extended JSON $numberDecimal values into
// Decimal128, which does not implement Number in the legacy driver.
return direction instanceof Decimal128 decimal
&& decimal.bigDecimalValue().compareTo(BigDecimal.ONE) == 0;
}
private static List<IndexInfo> listIndexInfos(MongoClient c, String database, String collection) {
List<IndexInfo> result = new ArrayList<>();
for (Document index : c.getDatabase(database).getCollection(collection).listIndexes()) {
@@ -992,7 +1151,7 @@ public final class MongoAgent {
private static Object dispatch(String method, JsonObject params) {
return switch (method) {
case AgentProtocol.METHOD_HANDSHAKE -> AgentProtocol.handshakeResult();
case AgentProtocol.METHOD_HANDSHAKE -> AgentProtocol.mongoLegacyHandshakeResult();
case AgentProtocol.METHOD_CONNECT -> connect(params);
case AgentProtocol.MONGO_METHOD_LIST_DATABASES -> listDatabases();
case AgentProtocol.MONGO_METHOD_LIST_COLLECTIONS -> listCollections(params);
@@ -1004,6 +1163,7 @@ public final class MongoAgent {
case AgentProtocol.MONGO_METHOD_CREATE_INDEX -> createIndex(params);
case AgentProtocol.MONGO_METHOD_DROP_INDEXES -> dropIndexes(params);
case AgentProtocol.MONGO_METHOD_DROP_COLLECTION -> dropCollection(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);
case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENTS -> updateDocuments(params);
@@ -1020,6 +1180,10 @@ public final class MongoAgent {
};
}
static AgentProtocol.HandshakeResult runtimeHandshakeResult() {
return AgentProtocol.mongoLegacyMultiSessionHandshakeResult();
}
private static MongoClient requireClient() {
MongoClient client = CURRENT_CLIENT.get();
if (client == null) {
@@ -1119,7 +1283,7 @@ public final class MongoAgent {
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = AgentProtocol.multiSessionHandshakeResult();
result = runtimeHandshakeResult();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import com.dbx.agent.AgentProtocol;
import com.dbx.agent.IndexInfo;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -113,6 +114,7 @@ class MongoAgentTest {
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_CONNECT));
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));
}
@Test
@@ -125,6 +127,15 @@ class MongoAgentTest {
assertFalse(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MULTI_SESSION));
}
@Test
void runtimeHandshakeAdvertisesDropDatabaseForMultiSessionConnections() {
JsonObject result = new Gson().toJsonTree(MongoAgent.runtimeHandshakeResult()).getAsJsonObject();
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));
}
@Test
void listIndexesMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
@@ -137,6 +148,14 @@ class MongoAgentTest {
assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method"));
}
@Test
void collectionSpecsPreserveCollectionKindsForTypeAwareClients() {
assertEquals(Map.of("name", "orders", "kind", "collection"), MongoAgent.collectionSpec("orders", "collection"));
assertEquals(Map.of("name", "report_view", "kind", "view"), MongoAgent.collectionSpec("report_view", "view"));
assertEquals(Map.of("name", "metrics", "kind", "timeseries"), MongoAgent.collectionSpec("metrics", "timeseries"));
assertEquals("collection", MongoAgent.collectionKind("futureType"));
}
@Test
void countDocumentsMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
@@ -289,6 +308,14 @@ class MongoAgentTest {
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_CREATE_INDEX));
}
@Test
void defaultIndexNameMatchesNativeDriverForWholeDoubles() {
assertEquals(
"email_1_createdAt_-1",
MongoAgent.defaultIndexName(Document.parse("{\"email\":1.0,\"createdAt\":-1.0}"))
);
}
@Test
void dropIndexesMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
@@ -303,6 +330,57 @@ class MongoAgentTest {
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_DROP_INDEXES));
}
@Test
void dropIndexesRejectsTheDefaultIdIndex() {
for (String indexesJson : List.of(
"\"_id_\"",
"{\"_id\":1}",
"{\"_id\":{\"$numberDecimal\":\"1.0\"}}",
"[\"email_1\",\"_id_\"]"
)) {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.parseDropIndexesValue(indexesJson, false)
);
assertEquals("The default MongoDB _id_ index cannot be dropped", error.getMessage());
}
assertThrows(
IllegalArgumentException.class,
() -> MongoAgent.parseDropIndexesValue("\"_id_\"", true)
);
assertEquals("*", MongoAgent.parseDropIndexesValue("\"*\"", false));
}
@Test
void batchDropIndexesReportsPartialFailuresAndContinues() {
List<String> calls = new ArrayList<>();
Map<String, Object> result = MongoAgent.dropNamedIndexes(List.of("email_1", "missing_1", "created_at_-1"), name -> {
calls.add(String.valueOf(name));
if ("missing_1".equals(name)) {
throw new IllegalStateException("index not found");
}
});
assertEquals(List.of("email_1", "missing_1", "created_at_-1"), calls);
assertEquals(List.of("email_1", "created_at_-1"), result.get("dropped_names"));
assertEquals(2, result.get("affected_rows"));
assertEquals(
List.of(Map.of("name", "missing_1", "message", "index not found")),
result.get("failures")
);
}
@Test
void batchDropIndexesUsesSerialFallbackOnlyBeforeMongo42() {
assertTrue(MongoAgent.serverVersionRequiresSerialDropIndexes("3.4.24"));
assertTrue(MongoAgent.serverVersionRequiresSerialDropIndexes("4.0.28"));
assertFalse(MongoAgent.serverVersionRequiresSerialDropIndexes("4.2.0"));
assertFalse(MongoAgent.serverVersionRequiresSerialDropIndexes("7.0.14"));
assertFalse(MongoAgent.serverVersionRequiresSerialDropIndexes("unknown"));
}
@Test
void dropCollectionMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
@@ -316,6 +394,20 @@ class MongoAgentTest {
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_DROP_COLLECTION));
}
@Test
void dropDatabaseMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":15,\"method\":\"drop_database\","
+ "\"params\":{\"database\":\"app\"}}"
);
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertEquals(15, json.get("id").getAsInt());
assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString());
assertFalse(json.getAsJsonObject("error").get("message").getAsString().contains("Unknown method"));
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_DROP_DATABASE));
}
@Test
void updateDocumentsMethodIsRecognizedOverJsonRpc() {
String response = MongoAgent.handleRequest(
@@ -38,6 +38,7 @@ import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/z
import { isLocalFileTypeDb } from "@/lib/connection/connectionFile";
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mq/mqPinnedVersionOptions";
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
import { applyDamengSslUrlParams, damengSslFormConfig } from "@/lib/database/damengSslOptions";
import { DamengJvmSystemPropertyError, damengJvmSystemPropertiesText, parseDamengJvmSystemProperties } from "@/lib/database/damengJvmOptions";
@@ -2884,7 +2885,7 @@ const mongoRetryWrites = computed({
},
});
const mongoDriverMode = computed({
get: () => (form.value.driver_profile === "mongodb-legacy" ? "legacy" : "auto"),
get: () => (isMongoLegacyDriverProfile(form.value.driver_profile) ? "legacy" : "auto"),
set: (value: string) => {
form.value.driver_profile = value === "legacy" ? "mongodb-legacy" : "mongodb";
form.value.driver_label = value === "legacy" ? "MongoDB (Legacy)" : "MongoDB";
@@ -3276,9 +3277,14 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
} else if (config.db_type === "mongodb") {
config.connection_string = normalizeMongoConnectionString(config.connection_string?.trim() || "");
}
if (config.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy") {
config.driver_profile = "mongodb";
config.driver_label = "MongoDB";
if (config.db_type === "mongodb") {
if (isMongoLegacyDriverProfile(config.driver_profile)) {
config.driver_profile = "mongodb-legacy";
config.driver_label = "MongoDB (Legacy)";
} else {
config.driver_profile = "mongodb";
config.driver_label = "MongoDB";
}
}
if (config.db_type === "mongodb") {
const mongoTls = normalizeMongoTlsFormState(!!config.ssl, config.url_params, config.ca_cert_path);
@@ -71,7 +71,7 @@ import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibili
import { useSettingsStore } from "@/stores/settingsStore";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -85,6 +85,7 @@ const props = defineProps<{
database: string;
collection: string;
databaseType?: DatabaseType;
tableMeta?: NonNullable<QueryTab["tableMeta"]>;
}>();
type JsonRecord = Record<string, unknown>;
@@ -1667,6 +1668,7 @@ defineExpose({ focusSearch });
:result="gridResult"
:connection-id="props.connectionId"
:database="props.database"
:table-meta="props.tableMeta"
:column-layout-scope-key="documentColumnLayoutScopeKey"
context="results"
:database-type="props.databaseType"
+87 -33
View File
@@ -221,6 +221,10 @@ import { supportsTableStructureEditing } from "@/lib/database/databaseCapabiliti
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
import { restoreDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { mongoCollectionSupportsIndexes, supportsMongoIndexMutations } from "@/lib/mongo/mongoCapabilities";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import { isProtectedMongoIndex, mongoDropAllIndexesPreview, mongoDropIndexFailureCount, mongoDropIndexPreview } from "@/lib/sidebar/mongoCollectionMutation";
import { runMongoMutation } from "@/lib/sidebar/runMongoSidebarMutation";
import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion";
import { isMacOS } from "@/lib/backend/platform";
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
@@ -7336,7 +7340,8 @@ function toggleCellDetailPanelLayout() {
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType));
const canOpenTableStructureEditor = computed(() => !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsTableStructureEditing(resolvedDatabaseType.value));
const mongoConnectionConfig = resolvedConnectionConfig;
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && mongoConnectionConfig.value?.db_type === "mongodb" && mongoConnectionConfig.value?.driver_profile !== "mongodb-legacy");
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsMongoIndexMutations(mongoConnectionConfig.value, props.tableMeta?.tableType));
const canShowTableIndexes = computed(() => tableMetadataCapabilities.value.indexes && (resolvedDatabaseType.value !== "mongodb" || mongoCollectionSupportsIndexes(props.tableMeta?.tableType)));
const tableInfoTabs = computed(() => {
const tabs: TableInfoTabItem[] = [];
if (tableMetadataCapabilities.value.ddl) {
@@ -7350,7 +7355,7 @@ const tableInfoTabs = computed(() => {
count: props.tableMeta?.columns.length,
});
}
if (tableMetadataCapabilities.value.indexes) {
if (canShowTableIndexes.value) {
tabs.push({ id: "indexes", label: t("grid.tableInfoIndexes"), icon: KeyRound, count: indexes.value.length });
}
if (tableMetadataCapabilities.value.foreignKeys) {
@@ -7435,6 +7440,19 @@ async function reloadIndexes() {
await fetchIndexes();
}
async function refreshMongoIndexMetadataAfterMutation() {
await reloadIndexes();
const connectionId = props.connectionId;
const database = props.database;
const collection = props.tableMeta?.tableName;
if (!connectionId || !database || !collection) return;
try {
await refreshLoadedMongoIndexes(connectionStore, { connectionId, database, collection });
} catch (e: any) {
toast(t("contextMenu.mongoIndexRefreshFailed", { message: String(e?.message || e) }), 5000);
}
}
async function fetchForeignKeys() {
const requestIdentity = currentForeignKeyTableIdentity.value;
if (!props.connectionId || !props.tableMeta || !requestIdentity || foreignKeysLoaded.value || foreignKeysLoading.value) return;
@@ -7775,7 +7793,8 @@ const filteredIndexes = computed(() => {
return indexes.value.filter((i) => i.name.toLowerCase().includes(q) || i.columns.some((c) => c.toLowerCase().includes(q)));
});
const droppableMongoIndexes = computed(() => indexes.value.filter((index) => !index.is_primary));
const droppableMongoIndexes = computed(() => indexes.value.filter((index) => !isProtectedMongoIndex(index)));
const dropMongoIndexConfirmMessage = computed(() =>
pendingDropMongoIndex.value
? t("contextMenu.confirmDropMongoIndexMessage", {
@@ -7784,51 +7803,86 @@ const dropMongoIndexConfirmMessage = computed(() =>
})
: "",
);
const dropMongoIndexPreview = computed(() => (pendingDropMongoIndex.value ? mongoDropIndexPreview(props.database || "", props.tableMeta?.tableName || "", pendingDropMongoIndex.value.name) : ""));
const dropAllMongoIndexesConfirmMessage = computed(() => t("contextMenu.confirmDropMongoAllIndexesMessage", { name: props.tableMeta?.tableName || "" }));
const dropAllMongoIndexesConfirmDetails = computed(() => t("contextMenu.confirmDropMongoAllIndexesDetails"));
const dropMongoIndexPreview = computed(() => (pendingDropMongoIndex.value ? `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndex(${JSON.stringify(pendingDropMongoIndex.value.name)})` : ""));
const dropAllMongoIndexesPreview = computed(() => `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndexes()`);
const dropAllMongoIndexesPreview = computed(() => mongoDropAllIndexesPreview(props.database || "", props.tableMeta?.tableName || ""));
function requestDropMongoIndex(index: IndexInfo) {
if (!canManageMongoIndexes.value || isProtectedMongoIndex(index)) return;
pendingDropMongoIndex.value = index;
showDropMongoIndexConfirm.value = true;
}
function requestDropAllMongoIndexes() {
if (!canManageMongoIndexes.value || droppableMongoIndexes.value.length === 0) return;
showDropAllMongoIndexesConfirm.value = true;
}
async function confirmDropMongoIndex() {
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || !pendingDropMongoIndex.value || dropMongoIndexLoading.value) return;
dropMongoIndexLoading.value = true;
try {
await connectionStore.ensureConnected(props.connectionId);
await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, JSON.stringify(pendingDropMongoIndex.value.name), true);
toast(t("contextMenu.dropTableChildObjectSuccess", { name: pendingDropMongoIndex.value.name }), 3000);
showDropMongoIndexConfirm.value = false;
pendingDropMongoIndex.value = null;
await reloadIndexes();
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
} finally {
dropMongoIndexLoading.value = false;
}
const index = pendingDropMongoIndex.value;
const connectionId = props.connectionId;
const database = props.database;
const tableName = props.tableMeta?.tableName;
if (!connectionId || !database || !tableName || !index || !canManageMongoIndexes.value || isProtectedMongoIndex(index) || dropMongoIndexLoading.value) return;
await runMongoMutation({
connection: connectionStore.getConfig(connectionId),
database,
reviewText: dropMongoIndexPreview.value,
source: t("production.sourceDataGrid"),
loading: dropMongoIndexLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
try {
return await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(index.name), true);
} finally {
await refreshMongoIndexMetadataAfterMutation();
}
},
onSuccess: (result) => {
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropTableChildObjectSuccess", { name: index.name }), 3000);
}
showDropMongoIndexConfirm.value = false;
pendingDropMongoIndex.value = null;
},
onError: (e: any) => toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000),
});
}
async function confirmDropAllMongoIndexes() {
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || dropAllMongoIndexesLoading.value) return;
dropAllMongoIndexesLoading.value = true;
try {
await connectionStore.ensureConnected(props.connectionId);
const result = await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, undefined, false);
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: props.tableMeta.tableName }), 3000);
showDropAllMongoIndexesConfirm.value = false;
await reloadIndexes();
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
} finally {
dropAllMongoIndexesLoading.value = false;
}
const connectionId = props.connectionId;
const database = props.database;
const tableName = props.tableMeta?.tableName;
if (!connectionId || !database || !tableName || !canManageMongoIndexes.value || dropAllMongoIndexesLoading.value) return;
await runMongoMutation({
connection: connectionStore.getConfig(connectionId),
database,
reviewText: dropAllMongoIndexesPreview.value,
source: t("production.sourceDataGrid"),
loading: dropAllMongoIndexesLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
try {
return await api.mongoDropIndexes(connectionId, database, tableName, undefined, false);
} finally {
await refreshMongoIndexMetadataAfterMutation();
}
},
onSuccess: (result) => {
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: tableName }), 3000);
}
showDropAllMongoIndexesConfirm.value = false;
},
onError: (e: any) => toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000),
});
}
const filteredForeignKeys = computed(() => {
@@ -9373,7 +9427,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ index.columns.join(", ") }}
</div>
</div>
<Button v-if="canManageMongoIndexes && !index.is_primary" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-[11px] text-destructive hover:text-destructive" @click="requestDropMongoIndex(index)">
<Button v-if="canManageMongoIndexes && !isProtectedMongoIndex(index)" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-[11px] text-destructive hover:text-destructive" @click="requestDropMongoIndex(index)">
<Trash2 class="mr-1 h-3 w-3" />
{{ t("contextMenu.dropIndex") }}
</Button>
@@ -1843,7 +1843,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
<template v-else-if="activeTab.mode === 'mongo'">
<div class="flex-1 min-h-0">
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
</div>
</template>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { toRefs, watch } from "vue";
import { AlertTriangle, Check, Loader2, Clipboard, Upload } from "@lucide/vue";
import { computed, toRefs, watch } from "vue";
import { AlertTriangle, Check, Loader2, Clipboard, Plus, Trash2, Upload } from "@lucide/vue";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
const props = defineProps<{ controller: Record<string, any> }>();
const emit = defineEmits<{ closed: [] }>();
@@ -94,6 +96,17 @@ const {
renameMongoCollectionPreview,
renameMongoCollectionLoading,
confirmRenameMongoCollection,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
mongoIndexKeyTypes,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
showRedisDatabaseAliasDialog,
redisDatabaseAliasInput,
redisDatabaseAliasSaving,
@@ -139,6 +152,14 @@ function closeCreateDatabaseResult() {
showCreateDatabasePreviewDialog.value = false;
}
const mongoIndexCollectionName = computed(() => node.value.tableName || node.value.label);
function mongoIndexTypeLabel(type: string): string {
if (type === "1") return t.value("contextMenu.mongoIndexAscending");
if (type === "-1") return t.value("contextMenu.mongoIndexDescending");
return type;
}
watch(
[
showDeleteConfirm,
@@ -155,6 +176,7 @@ watch(
showCreateNacosNamespaceDialog,
showEditNacosNamespaceDialog,
showRenameMongoCollectionDialog,
showCreateMongoIndexDialog,
showRedisDatabaseAliasDialog,
showCreateSchemaDialog,
showEditSchemaCommentDialog,
@@ -255,6 +277,73 @@ watch(
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateMongoIndexDialog">
<DialogContent class="min-w-0 sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.createMongoIndexTitle", { collection: mongoIndexCollectionName }) }}</DialogTitle>
<p class="font-mono text-xs text-muted-foreground">{{ node.database }} / {{ mongoIndexCollectionName }}</p>
</DialogHeader>
<div class="grid min-w-0 gap-5">
<section class="grid min-w-0 gap-2">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-medium">{{ t("contextMenu.createMongoIndexFields") }}</span>
<Button type="button" variant="outline" size="sm" :disabled="mongoCreateIndexLoading || !mongoCreateIndexCanAddField" @click="addMongoCreateIndexField">
<Plus class="mr-1 h-4 w-4" />
{{ t("mongo.addField") }}
</Button>
</div>
<div class="flex min-w-0 gap-2 px-0.5 text-xs text-muted-foreground">
<span class="min-w-0 flex-1">{{ t("mongo.field") }}</span>
<span class="w-36 shrink-0">{{ t("structureEditor.indexType") }}</span>
<span class="w-8 shrink-0"></span>
</div>
<div v-for="field in mongoCreateIndexForm.fields" :key="field.id" class="flex min-w-0 items-center gap-2">
<Input v-model="field.path" :list="`mongo-index-fields-${field.id}`" :disabled="mongoCreateIndexLoading" :placeholder="t('mongo.fieldPlaceholder')" :aria-label="t('mongo.field')" class="h-8 min-w-0 flex-1" autocomplete="off" />
<datalist :id="`mongo-index-fields-${field.id}`">
<option v-for="option in mongoCreateIndexFieldOptions" :key="option" :value="option"></option>
</datalist>
<Select v-model="field.type" :disabled="mongoCreateIndexLoading">
<SelectTrigger class="h-8 w-36 shrink-0" :aria-label="t('structureEditor.indexType')">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="type in mongoIndexKeyTypes" :key="type" :value="type">{{ mongoIndexTypeLabel(type) }}</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :disabled="mongoCreateIndexLoading || mongoCreateIndexForm.fields.length === 1" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click="removeMongoCreateIndexField(field.id)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</section>
<label class="grid gap-1.5 text-sm font-medium">
{{ t("structureEditor.indexName") }}
<Input v-model="mongoCreateIndexForm.name" :disabled="mongoCreateIndexLoading" :placeholder="t('contextMenu.createMongoIndexNamePlaceholder')" :aria-label="t('structureEditor.indexName')" />
<span class="text-xs font-normal text-muted-foreground">{{ t("contextMenu.createMongoIndexNameHint") }}</span>
</label>
<div class="grid gap-2 rounded-md border p-3 sm:grid-cols-2">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium">
{{ t("structureEditor.unique") }}
<Switch v-model="mongoCreateIndexForm.unique" :disabled="mongoCreateIndexLoading" :aria-label="t('structureEditor.unique')" />
</label>
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium">
{{ t("contextMenu.createMongoIndexSparse") }}
<Switch v-model="mongoCreateIndexForm.sparse" :disabled="mongoCreateIndexLoading" :aria-label="t('contextMenu.createMongoIndexSparse')" />
</label>
</div>
<p v-if="mongoCreateIndexError" class="min-w-0 max-w-full whitespace-pre-wrap break-all text-sm text-destructive">{{ mongoCreateIndexError }}</p>
</div>
<DialogFooter>
<Button variant="outline" :disabled="mongoCreateIndexLoading" @click="showCreateMongoIndexDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="mongoCreateIndexLoading || !mongoCreateIndexCanSubmit" @click="confirmCreateMongoIndex">
<Loader2 v-if="mongoCreateIndexLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("contextMenu.createMongoIndex") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showRedisDatabaseAliasDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
@@ -93,6 +93,7 @@ import {
isSingleDatabase,
} from "@/lib/database/databaseCapabilities";
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
import { handleSidebarTreeDeleteShortcut } from "@/lib/sidebar/sidebarTreeDeleteShortcut";
@@ -148,7 +149,7 @@ import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/s
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
import { flattenTree } from "@/composables/useFlatTree";
import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { createSidebarMenuContext, normalizeSidebarMenuDescriptors } from "@/lib/sidebar/sidebarTreeMenuDescriptors";
@@ -234,6 +235,11 @@ import {
dropMongoIndexLoading,
showDropAllMongoIndexesConfirm,
dropAllMongoIndexesLoading,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
showFlushRedisDbConfirm,
showCreateSchemaDialog,
createSchemaName,
@@ -392,9 +398,18 @@ const {
mongoIndexNameForNode,
canDropMongoIndexNode,
canDropMongoIndex,
mongoIndexDropPreview,
canDropAllMongoIndexes,
mongoIndexDropPreview,
mongoDropAllIndexesPreview,
refreshMongoIndexTreeAfterMutation,
canCreateMongoIndex,
mongoIndexKeyTypes,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
prepareCreateMongoIndexDialog,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
openCreateNacosNamespaceDialog,
confirmCreateNacosNamespace,
openEditNacosNamespaceDialog,
@@ -973,6 +988,12 @@ function openRenameMongoCollectionDialog() {
prepareRenameMongoCollectionDialog();
}
function openCreateMongoIndexDialog() {
claimTreeItemDialogOwnership();
routeTreeItemDialogController();
prepareCreateMongoIndexDialog();
}
function openRedisDatabaseAliasDialog() {
claimTreeItemDialogOwnership();
routeTreeItemDialogController();
@@ -1080,6 +1101,13 @@ function openMongoTreeData(node: TreeNode) {
if (node.type !== "mongo-collection") return;
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
queryStore.updateSql(tab, node.label);
queryStore.setTableMeta(tab, {
database: node.database,
tableName: node.label,
tableType: mongoCollectionTableTypeFromNode(node),
columns: [],
primaryKeys: [],
});
}
async function openSavedSqlFile() {
@@ -2076,27 +2104,61 @@ async function confirmBatchDrop() {
if (mongoIndexTargets.length) {
const grouped = new Map<string, TreeNode[]>();
for (const target of mongoIndexTargets) {
const key = `${target.connectionId}:${target.database}:${target.tableName || ""}`;
const key = JSON.stringify([target.connectionId, target.database, target.tableName || ""]);
const list = grouped.get(key) ?? [];
list.push(target);
grouped.set(key, list);
}
const mongoIndexGroups = [...grouped.values()];
// Confirm every production target before changing any collection so a
// cancellation cannot leave a cross-database batch partially applied.
for (const groupTargets of mongoIndexGroups) {
const groupFirst = groupTargets[0];
if (!groupFirst?.connectionId || !groupFirst.database || !groupFirst.tableName) return;
const confirmed = await executeWithProductionContextGuard({
connection: connectionStore.getConfig(groupFirst.connectionId),
database: groupFirst.database,
reviewText: groupTargets.map((target) => mongoIndexDropPreview(target, mongoIndexNameForNode(target))).join("\n"),
source: t("production.sourceSidebar"),
execute: async () => ({ confirmed: true }),
});
if (confirmed === undefined) return;
}
let droppedCount = 0;
for (const groupTargets of grouped.values()) {
const first = groupTargets[0];
if (!first?.connectionId || !first.database || !first.tableName) continue;
await connectionStore.ensureConnected(first.connectionId);
let failedCount = 0;
let firstGroupError: unknown;
for (const groupTargets of mongoIndexGroups) {
const groupFirst = groupTargets[0];
if (!groupFirst?.connectionId || !groupFirst.database || !groupFirst.tableName) continue;
const names = groupTargets.map((target) => mongoIndexNameForNode(target));
const result = await api.mongoDropIndexes(first.connectionId, first.database, first.tableName, JSON.stringify(names.length === 1 ? names[0] : names), false);
const dropped = new Set(result.dropped_names);
droppedCount += result.dropped_names.length;
for (const target of groupTargets) {
if (!dropped.has(mongoIndexNameForNode(target))) continue;
connectionStore.removeTreeNode(target.id);
releaseActiveNodeReference([target.id]);
try {
await connectionStore.ensureConnected(groupFirst.connectionId);
const result = await api.mongoDropIndexes(groupFirst.connectionId, groupFirst.database, groupFirst.tableName, JSON.stringify(names.length === 1 ? names[0] : names), names.length === 1);
const dropped = new Set(result.dropped_names);
droppedCount += result.dropped_names.length;
failedCount += mongoDropIndexFailureCount(result);
for (const target of groupTargets) {
const indexName = mongoIndexNameForNode(target);
if (!dropped.has(indexName)) continue;
connectionStore.removeTreeNode(target.id);
releaseActiveNodeReference([target.id]);
}
} catch (error) {
// A transport-level failure has no per-index payload; retain prior
// successes and continue with independent collection groups.
failedCount += groupTargets.length;
firstGroupError ??= error;
} finally {
await refreshMongoIndexTreeAfterMutation(groupFirst);
}
}
toast(t("contextMenu.batchDropSuccess", { count: droppedCount }), 3000);
if (droppedCount === 0 && firstGroupError && failedCount === mongoIndexTargets.length) throw firstGroupError;
if (failedCount > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: droppedCount, failed: failedCount }), 5000);
} else {
toast(t("contextMenu.batchDropSuccess", { count: droppedCount }), 3000);
}
showBatchDropConfirm.value = false;
return;
}
@@ -3663,6 +3725,17 @@ function databaseSpecificDialogCapabilities() {
renameMongoCollectionPreview,
renameMongoCollectionLoading,
confirmRenameMongoCollection,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
mongoIndexKeyTypes,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
showRedisDatabaseAliasDialog,
redisDatabaseAliasInput,
redisDatabaseAliasSaving,
@@ -4213,8 +4286,11 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
shortcut: shortcutRename,
});
}
if (canDropAllMongoIndexes.value || canDropMongoCollection.value) {
if (canCreateMongoIndex.value || canDropAllMongoIndexes.value || canDropMongoCollection.value) {
items.push({ label: "", separator: true });
if (canCreateMongoIndex.value) {
items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus });
}
if (canDropAllMongoIndexes.value) {
items.push({ label: t("contextMenu.dropAllIndexes"), action: dropAllMongoIndexes, icon: Trash2, variant: "destructive" as const });
}
@@ -4486,7 +4562,8 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
// 9. Group Labels (group-columns, group-tables, etc.)
if (isGroupLabel(node)) {
const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null;
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate;
const hasMongoCreateIndexAction = node.type === "group-indexes" && canCreateMongoIndex.value;
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate || hasMongoCreateIndexAction;
const canLoadAllObjectGroup = node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views";
if (node.type === "group-tables" && canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
@@ -4503,6 +4580,9 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
if (mysqlObjectTemplate) {
items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus });
}
if (hasMongoCreateIndexAction) {
items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus });
}
if (hasGroupCreateAction) {
items.push({ label: "", separator: true });
}
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import SidebarTreeItemDialogs from "../SidebarTreeItemDialogs.vue";
const mountedApps: ReturnType<typeof createApp>[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("SidebarTreeItemDialogs MongoDB index form", () => {
it("mounts the form and overlay from the same controlled dialog state", async () => {
const confirmCreateMongoIndex = vi.fn();
const closed = vi.fn();
const controller = reactive({
node: { id: "mongo:app:users:__indexes", label: "索引", type: "group-indexes", database: "app", tableName: "users" },
t: (key: string) => key,
highlight: (value: string) => value,
showDeleteConfirm: false,
showMoveToNewGroupDialog: false,
showDeleteGroupConfirm: false,
showRenameObjectDialog: false,
showStructurePreviewDialog: false,
showStructureDocCopyDialog: false,
showDuplicateDialog: false,
showPasteDialog: false,
showCreateDatabaseDialog: false,
showCreateDatabasePreviewDialog: false,
showEditDatabasePropertiesDialog: false,
showCreateNacosNamespaceDialog: false,
showEditNacosNamespaceDialog: false,
showRenameMongoCollectionDialog: false,
showCreateMongoIndexDialog: false,
mongoCreateIndexForm: { name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false },
mongoCreateIndexFieldOptions: ["email", "createdAt"],
mongoCreateIndexError: `Kind: Command failed: Error code 11000 (DuplicateKey): ${"a".repeat(400)}`,
mongoCreateIndexLoading: false,
mongoIndexKeyTypes: ["1", "-1", "text", "hashed", "2dsphere", "2d"],
mongoCreateIndexCanSubmit: false,
mongoCreateIndexCanAddField: false,
addMongoCreateIndexField: vi.fn(),
removeMongoCreateIndexField: vi.fn(),
confirmCreateMongoIndex,
showRedisDatabaseAliasDialog: false,
showCreateSchemaDialog: false,
showEditSchemaCommentDialog: false,
});
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeItemDialogs, { controller, onClosed: closed }),
}),
);
mountedApps.push(app);
app.mount(document.createElement("div"));
await nextTick();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
controller.showCreateMongoIndexDialog = true;
await nextTick();
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
const name = document.querySelector<HTMLInputElement>('[aria-label="structureEditor.indexName"]');
const type = document.querySelector<HTMLElement>('[aria-label="structureEditor.indexType"]');
const unique = document.querySelector<HTMLElement>('[aria-label="structureEditor.unique"]');
expect(dialog?.getAttribute("data-state")).toBe("open");
expect(document.querySelectorAll('[data-slot="dialog-overlay"]')).toHaveLength(1);
expect(dialog?.contains(name!)).toBe(true);
expect(dialog?.contains(type!)).toBe(true);
expect(dialog?.contains(unique!)).toBe(true);
expect(dialog?.textContent).not.toContain("JSON");
const error = dialog?.querySelector<HTMLElement>(".text-destructive");
expect(error?.classList.contains("min-w-0")).toBe(true);
expect(error?.classList.contains("max-w-full")).toBe(true);
expect(error?.classList.contains("whitespace-pre-wrap")).toBe(true);
expect(error?.classList.contains("break-all")).toBe(true);
unique?.click();
await nextTick();
expect(controller.mongoCreateIndexForm.unique).toBe(true);
controller.mongoCreateIndexForm.fields[0].path = "email";
controller.mongoCreateIndexForm.name = "codex_e2e_email_1";
controller.mongoCreateIndexCanSubmit = true;
await nextTick();
const createButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "contextMenu.createMongoIndex");
expect(createButton?.disabled).toBe(false);
createButton?.click();
expect(confirmCreateMongoIndex).toHaveBeenCalledOnce();
controller.showCreateMongoIndexDialog = false;
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
});
expect(closed).toHaveBeenCalledOnce();
});
});
@@ -4,6 +4,7 @@ import type { PasteTableMode } from "@/lib/table/tableClipboard";
import { fallbackCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import type { DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
import type { AuthorizationPlan, AuthorizationStepResult } from "@/lib/database/databaseAuthorizationPlan";
import type { MongoCreateIndexForm } from "@/lib/sidebar/mongoCollectionMutation";
export type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
type ConnectionDeleteTarget = TreeNode & { connectionId: string };
@@ -96,6 +97,18 @@ export const showDropMongoIndexConfirm = ref(false);
export const dropMongoIndexLoading = ref(false);
export const showDropAllMongoIndexesConfirm = ref(false);
export const dropAllMongoIndexesLoading = ref(false);
export const showCreateMongoIndexDialog = ref(false);
export const mongoCreateIndexForm = ref<MongoCreateIndexForm>({ name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false });
export const mongoCreateIndexFieldOptions = ref<string[]>([]);
export const mongoCreateIndexError = ref("");
export const mongoCreateIndexLoading = ref(false);
export function resetMongoCreateIndexForm() {
mongoCreateIndexForm.value = { name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false };
mongoCreateIndexFieldOptions.value = [];
mongoCreateIndexError.value = "";
mongoCreateIndexLoading.value = false;
}
export const showFlushRedisDbConfirm = ref(false);
export const showRedisDatabaseAliasDialog = ref(false);
export const redisDatabaseAliasInput = ref("");
@@ -141,6 +154,7 @@ const openFlags = [
showRenameMongoCollectionDialog,
showDropMongoIndexConfirm,
showDropAllMongoIndexesConfirm,
showCreateMongoIndexDialog,
showFlushRedisDbConfirm,
showRedisDatabaseAliasDialog,
showCreateSchemaDialog,
@@ -162,6 +176,7 @@ export function resetSidebarTreeDialogState() {
createDatabaseAuthorizationApplying.value = false;
redisDatabaseAliasInput.value = "";
redisDatabaseAliasSaving.value = false;
resetMongoCreateIndexForm();
sidebarTreeDialogOwner.value = null;
sidebarDangerTarget.value = null;
sidebarFormTarget.value = null;
@@ -0,0 +1,435 @@
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";
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
ensureConnected: vi.fn().mockResolvedValue(undefined),
loadIndexes: vi.fn().mockResolvedValue(undefined),
listMongoCompletionFields: vi.fn().mockResolvedValue([{ name: "email", type: "string" }]),
loadMongoCollections: vi.fn().mockResolvedValue(undefined),
loadMongoDatabases: vi.fn().mockResolvedValue(undefined),
removeTreeNode: vi.fn(),
mongoCreateIndex: vi.fn().mockResolvedValue({ name: "email_1" }),
mongoDropCollection: vi.fn().mockResolvedValue(undefined),
mongoDropDatabase: vi.fn().mockResolvedValue(undefined),
mongoDropIndexes: vi.fn().mockResolvedValue({ dropped_names: ["email_1"], affected_rows: 1 }),
getConfig: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key),
}),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({}),
}));
vi.mock("@/lib/backend/api", () => ({
mongoCreateIndex: (...args: unknown[]) => mocks.mongoCreateIndex(...args),
mongoDropCollection: (...args: unknown[]) => mocks.mongoDropCollection(...args),
mongoDropDatabase: (...args: unknown[]) => mocks.mongoDropDatabase(...args),
mongoDropIndexes: (...args: unknown[]) => mocks.mongoDropIndexes(...args),
mongoRenameCollection: vi.fn(),
nacosCreateNamespace: vi.fn(),
nacosUpdateNamespace: vi.fn(),
redisFlushDb: vi.fn(),
}));
vi.mock("@/lib/sidebar/sidebarActionTarget", () => ({
findSidebarActionTarget: () => null,
}));
import { useSidebarDatabaseSpecificMutationRuntime } from "@/composables/useSidebarDatabaseSpecificMutationRuntime";
function mongoConfig(driverProfile?: string, production = false) {
return {
id: "conn-1",
name: "Mongo",
db_type: "mongodb" as const,
driver_profile: driverProfile,
host: "localhost",
port: 27017,
username: "op",
password: "",
is_production: production,
};
}
function mongoDatabaseNode(): TreeNode {
return {
id: "conn-1:app",
label: "app",
type: "mongo-db",
connectionId: "conn-1",
database: "app",
isExpanded: false,
};
}
function mongoCollectionNode(kind: "collection" | "view" | "timeseries" = "collection"): TreeNode {
return {
id: "conn-1:app:users",
label: "users",
type: "mongo-collection",
connectionId: "conn-1",
database: "app",
meta: { collectionKind: kind },
isExpanded: false,
};
}
function mongoIndexesGroupNode(kind: "collection" | "view" | "timeseries" = "collection"): TreeNode {
return {
id: "conn-1:app:users:__indexes",
label: "tree.indexes",
type: "group-indexes",
connectionId: "conn-1",
database: "app",
tableName: "users",
meta: { collectionKind: kind },
isExpanded: false,
children: [],
};
}
function mongoIndexNode(name: string, kind: "collection" | "view" | "timeseries" = "collection", isPrimary = name === "_id_"): TreeNode {
return {
id: `conn-1:app:users:__indexes:${name}`,
label: `${name} (email)`,
type: "index",
connectionId: "conn-1",
database: "app",
tableName: "users",
meta: { name, columns: ["email"], is_primary: isPrimary, is_unique: false, collectionKind: kind },
isExpanded: false,
};
}
function runtime(activeNode: TreeNode) {
const indexesGroup = activeNode.type === "group-indexes" ? activeNode : mongoIndexesGroupNode();
return useSidebarDatabaseSpecificMutationRuntime({
activeNode: shallowRef(activeNode),
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
removeTreeNode: mocks.removeTreeNode,
treeNodes: [indexesGroup],
} as any,
});
}
describe("MongoDB sidebar mutation runtime", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mocks.getConfig.mockReturnValue(mongoConfig());
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.loadIndexes.mockResolvedValue(undefined);
mocks.listMongoCompletionFields.mockResolvedValue([{ name: "email", type: "string" }]);
mocks.loadMongoCollections.mockResolvedValue(undefined);
mocks.loadMongoDatabases.mockResolvedValue(undefined);
mocks.mongoCreateIndex.mockResolvedValue({ name: "email_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;
showDropAllMongoIndexesConfirm.value = false;
showDropMongoCollectionConfirm.value = false;
resetMongoCreateIndexForm();
});
it("allows Legacy connections to create and delete MongoDB tree objects while keeping rename native-only", () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const activeNode = shallowRef(mongoDatabaseNode());
const feature = useSidebarDatabaseSpecificMutationRuntime({
activeNode,
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
treeNodes: [],
} as any,
});
expect(feature.canDropMongoDatabase.value).toBe(true);
activeNode.value = mongoCollectionNode();
expect(feature.canDropMongoCollection.value).toBe(true);
expect(feature.canDropAllMongoIndexes.value).toBe(true);
expect(feature.canRenameMongoCollection.value).toBe(false);
expect(feature.canCreateMongoIndex.value).toBe(true);
activeNode.value = mongoIndexesGroupNode();
expect(feature.canCreateMongoIndex.value).toBe(true);
activeNode.value = mongoIndexNode("email_1");
expect(feature.canDropMongoIndex.value).toBe(true);
activeNode.value = mongoIndexNode("_id_");
expect(feature.canDropMongoIndex.value).toBe(false);
});
it("keeps collection deletion available for views without exposing unsupported index actions", () => {
const activeNode = shallowRef(mongoCollectionNode("view"));
const feature = useSidebarDatabaseSpecificMutationRuntime({
activeNode,
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
treeNodes: [],
} as any,
});
expect(feature.canDropMongoCollection.value).toBe(true);
expect(feature.canDropAllMongoIndexes.value).toBe(false);
activeNode.value = mongoIndexesGroupNode("view");
expect(feature.canCreateMongoIndex.value).toBe(false);
activeNode.value = mongoIndexNode("email_1", "view");
expect(feature.canDropMongoIndex.value).toBe(false);
});
it("creates an index from the shared sidebar dialog state", async () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const node = mongoIndexesGroupNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value = {
name: "email_created_at",
fields: [
{ id: 1, path: "email", type: "1" },
{ id: 2, path: "createdAt", type: "-1" },
],
unique: true,
sparse: false,
};
await feature.confirmCreateMongoIndex();
expect(showCreateMongoIndexDialog.value).toBe(false);
expect(mocks.ensureConnected).toHaveBeenCalledWith("conn-1");
expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1,"createdAt":-1}', '{"name":"email_created_at","unique":true}');
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
expect(mocks.toast).toHaveBeenCalledWith('contextMenu.createMongoIndexSuccess:{"name":"email_1","collection":"users"}', 3000);
});
it("starts every create-index dialog with safe defaults", () => {
const feature = runtime(mongoIndexesGroupNode());
mongoCreateIndexForm.value = {
name: "stale_index",
fields: [{ id: 7, path: "location", type: "2dsphere" }],
unique: true,
sparse: true,
};
mongoCreateIndexError.value = "previous failure";
feature.prepareCreateMongoIndexDialog();
expect(mongoCreateIndexForm.value).toEqual({
name: "",
fields: [{ id: 1, path: "", type: "1" }],
unique: false,
sparse: false,
});
expect(mongoCreateIndexError.value).toBe("");
expect(showCreateMongoIndexDialog.value).toBe(true);
});
it("allows a collection menu to create an index before its Indexes group is expanded", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
await feature.confirmCreateMongoIndex();
expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1}', undefined);
});
it("keeps the sidebar form target when the active node changes", async () => {
const originalTarget = mongoIndexesGroupNode();
const activeNode = shallowRef(originalTarget);
const feature = useSidebarDatabaseSpecificMutationRuntime({
activeNode,
connectionStore: {
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
loadIndexes: mocks.loadIndexes,
listMongoCompletionFields: mocks.listMongoCompletionFields,
loadMongoCollections: mocks.loadMongoCollections,
loadMongoDatabases: mocks.loadMongoDatabases,
treeNodes: [originalTarget],
} as any,
});
sidebarFormTarget.value = originalTarget;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
activeNode.value = {
...mongoIndexesGroupNode(),
id: "conn-1:app:orders:__indexes",
tableName: "orders",
};
await feature.confirmCreateMongoIndex();
expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1}', undefined);
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
});
it("drops every removable index through the shared mutation and refreshes metadata", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarDangerTarget.value = node;
showDropAllMongoIndexesConfirm.value = true;
mocks.mongoDropIndexes.mockResolvedValueOnce({ dropped_names: ["email_1", "created_at_-1"], affected_rows: 2 });
await feature.confirmDropAllMongoIndexes();
expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, false);
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
expect(showDropAllMongoIndexesConfirm.value).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith('contextMenu.dropAllIndexesSuccess:{"count":2,"name":"users"}', 3000);
});
it("refreshes index metadata after a failed delete request", async () => {
const node = mongoIndexNode("email_1");
const feature = runtime(node);
sidebarDangerTarget.value = node;
mocks.mongoDropIndexes.mockRejectedValueOnce(new Error("connection lost"));
await feature.confirmDropMongoIndex();
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.tableOperationFailed"), 5000);
});
it("reports partial index deletion after forcing a metadata refresh", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarDangerTarget.value = node;
mocks.mongoDropIndexes.mockResolvedValueOnce({
dropped_names: ["email_1"],
affected_rows: 1,
failures: [{ name: "missing_1", message: "index not found" }],
});
await feature.confirmDropAllMongoIndexes();
expect(mocks.loadIndexes).toHaveBeenCalledOnce();
expect(mocks.toast).toHaveBeenCalledWith('contextMenu.dropIndexesPartialFailure:{"success":1,"failed":1}', 5000);
});
it("executes Legacy delete operations and refreshes their MongoDB metadata", async () => {
mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy"));
const databaseNode = mongoDatabaseNode();
const databaseFeature = runtime(databaseNode);
sidebarDangerTarget.value = databaseNode;
await databaseFeature.confirmDropMongoDatabase();
expect(mocks.mongoDropDatabase).toHaveBeenCalledWith("conn-1", "app");
expect(mocks.loadMongoDatabases).toHaveBeenCalledWith("conn-1");
const collectionNode = mongoCollectionNode();
const collectionFeature = runtime(collectionNode);
sidebarDangerTarget.value = collectionNode;
await collectionFeature.confirmDropMongoCollection();
expect(mocks.mongoDropCollection).toHaveBeenCalledWith("conn-1", "app", "users");
expect(mocks.loadMongoDatabases).toHaveBeenCalledWith("conn-1");
expect(mocks.loadMongoCollections).toHaveBeenCalledWith("conn-1", "app");
const indexNode = mongoIndexNode("email_1");
const indexFeature = runtime(indexNode);
sidebarDangerTarget.value = indexNode;
await indexFeature.confirmDropMongoIndex();
expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", '"email_1"', true);
expect(mocks.loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, "conn-1:app:users:__indexes", undefined);
});
it("keeps a completed collection drop successful when metadata refresh fails", async () => {
const node = mongoCollectionNode();
const feature = runtime(node);
sidebarDangerTarget.value = node;
showDropMongoCollectionConfirm.value = true;
mocks.loadMongoDatabases.mockRejectedValueOnce(new Error("metadata unavailable"));
await feature.confirmDropMongoCollection();
expect(mocks.mongoDropCollection).toHaveBeenCalledWith("conn-1", "app", "users");
expect(showDropMongoCollectionConfirm.value).toBe(false);
expect(mocks.removeTreeNode).toHaveBeenCalledWith(node.id);
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.dropCollectionSuccess"), 3000);
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.objectDropRefreshFailed"), 5000);
expect(mocks.toast).not.toHaveBeenCalledWith(expect.stringContaining("contextMenu.tableOperationFailed"), 5000);
});
it("does not send a default _id_ index deletion request", async () => {
const node = mongoIndexNode("_id_");
const feature = runtime(node);
sidebarDangerTarget.value = node;
await feature.confirmDropMongoIndex();
expect(mocks.mongoDropIndexes).not.toHaveBeenCalled();
});
it("also hides indexes marked primary when metadata has an unexpected name", () => {
const feature = runtime(mongoIndexNode("unexpected_primary_name", "collection", true));
expect(feature.canDropMongoIndex.value).toBe(false);
});
it("reports an index-list refresh problem without misreporting a completed create as failed", async () => {
const node = mongoIndexesGroupNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
mocks.loadIndexes.mockRejectedValue(new Error("metadata unavailable"));
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
await feature.confirmCreateMongoIndex();
expect(mocks.mongoCreateIndex).toHaveBeenCalledOnce();
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("contextMenu.mongoIndexRefreshFailed"), 5000);
});
it("does not issue a create request when production confirmation is cancelled", async () => {
mocks.getConfig.mockReturnValue(mongoConfig(undefined, true));
const node = mongoIndexesGroupNode();
const feature = runtime(node);
sidebarFormTarget.value = node;
feature.prepareCreateMongoIndexDialog();
mongoCreateIndexForm.value.fields[0]!.path = "email";
const pending = feature.confirmCreateMongoIndex();
await Promise.resolve();
const { useProductionSafetyStore } = await import("@/stores/productionSafetyStore");
useProductionSafetyStore().cancel();
await pending;
expect(mocks.ensureConnected).not.toHaveBeenCalled();
expect(mocks.mongoCreateIndex).not.toHaveBeenCalled();
expect(showCreateMongoIndexDialog.value).toBe(true);
});
});
@@ -7,8 +7,23 @@ import * as api from "@/lib/backend/api";
import { translateBackendError } from "@/i18n/backend-errors";
import { notifyNacosNamespacesChanged } from "@/lib/nacos/nacosNamespaceCache";
import { findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { isRenamableMongoCollection, mongoCollectionKindFromNode, mongoDropAllIndexesPreview, mongoDropCollectionPreview, mongoDropDatabasePreview, mongoDropIndexPreview, mongoRenameCollectionPreview } from "@/lib/sidebar/mongoCollectionMutation";
import {
MONGO_INDEX_KEY_TYPES,
buildMongoCreateIndexRequest,
isProtectedMongoIndex,
isRenamableMongoCollection,
mongoCollectionKindFromNode,
mongoCreateIndexPreview,
mongoDropAllIndexesPreview,
mongoDropCollectionPreview,
mongoDropIndexFailureCount,
mongoDropDatabasePreview,
mongoDropIndexPreview,
mongoRenameCollectionPreview,
} from "@/lib/sidebar/mongoCollectionMutation";
import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import {
sidebarDangerTarget,
sidebarFormTarget,
@@ -38,6 +53,12 @@ import {
renameMongoCollectionError,
renameMongoCollectionPreview,
renameMongoCollectionLoading,
showCreateMongoIndexDialog,
mongoCreateIndexForm,
mongoCreateIndexFieldOptions,
mongoCreateIndexError,
mongoCreateIndexLoading,
resetMongoCreateIndexForm,
} from "@/components/sidebar/sidebarTreeDialogState";
interface SidebarDatabaseSpecificMutationRuntimeOptions {
@@ -57,19 +78,27 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const { toast } = useToast();
const { activeNode, connectionStore } = options;
const canDropMongoDatabase = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "mongo-db" && !!activeNode.value.database && config?.driver_profile !== "mongodb-legacy";
});
function usesAnyMongoDriver(node: Pick<TreeNode, "connectionId">): boolean {
return !!node.connectionId && supportsMongoAllDriverMutations(connectionStore.getConfig(node.connectionId));
}
function usesNativeMongoDriver(node: Pick<TreeNode, "connectionId">): boolean {
return !!node.connectionId && supportsNativeMongoDriverMutations(connectionStore.getConfig(node.connectionId));
}
function canMutateMongoIndexes(node: TreeNode): boolean {
return !!node.connectionId && supportsMongoIndexMutations(connectionStore.getConfig(node.connectionId), mongoCollectionKindFromNode(node));
}
const canDropMongoDatabase = computed(() => activeNode.value.type === "mongo-db" && !!activeNode.value.database && usesAnyMongoDriver(activeNode.value));
function canMutateMongoCollectionNode(node: TreeNode): boolean {
if (node.type !== "mongo-collection" || !node.connectionId || !node.database) return false;
const config = connectionStore.getConfig(node.connectionId);
return config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy";
return usesAnyMongoDriver(node);
}
function canRenameMongoCollectionNode(node: TreeNode): boolean {
return canMutateMongoCollectionNode(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node));
return canMutateMongoCollectionNode(node) && usesNativeMongoDriver(node) && isRenamableMongoCollection(node.label, mongoCollectionKindFromNode(node));
}
const canDropMongoCollection = computed(() => canMutateMongoCollectionNode(activeNode.value));
@@ -140,22 +169,104 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
function canDropMongoIndexNode(node: TreeNode): boolean {
if (node.type !== "index" || !node.connectionId || !node.database || !node.tableName) return false;
const config = connectionStore.getConfig(node.connectionId);
return config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy" && mongoIndexNameForNode(node) !== "_id_";
const isPrimary = !!(node.meta && "is_primary" in node.meta && node.meta.is_primary);
return canMutateMongoIndexes(node) && !isProtectedMongoIndex({ name: mongoIndexNameForNode(node), is_primary: isPrimary });
}
const canDropMongoIndex = computed(() => canDropMongoIndexNode(activeNode.value));
const canDropAllMongoIndexes = computed(() => activeNode.value.type === "mongo-collection" && canMutateMongoIndexes(activeNode.value));
function mongoIndexDropPreview(node: Pick<TreeNode, "database" | "tableName">, indexName: string): string {
return mongoDropIndexPreview(node.database || "", node.tableName || "", indexName);
}
const canDropAllMongoIndexes = computed(() => canMutateMongoCollectionNode(activeNode.value));
function mongoDropAllIndexesPreviewForNode(node: Pick<TreeNode, "database" | "label">): string {
return mongoDropAllIndexesPreview(node.database || "", node.label);
}
function canCreateMongoIndexNode(node: TreeNode): boolean {
const collectionName = mongoIndexCollectionName(node);
return !!collectionName && !!node.database && canMutateMongoIndexes(node);
}
const canCreateMongoIndex = computed(() => canCreateMongoIndexNode(activeNode.value));
const mongoCreateIndexCanSubmit = computed(() => mongoCreateIndexForm.value.fields.length > 0 && mongoCreateIndexForm.value.fields.every((field) => !!field.path.trim()));
const mongoCreateIndexCanAddField = computed(() => mongoCreateIndexForm.value.fields.every((field) => !!field.path.trim()));
watch(
mongoCreateIndexForm,
() => {
mongoCreateIndexError.value = "";
},
{ deep: true },
);
function mongoIndexCollectionName(node: TreeNode): string {
if (node.type === "mongo-collection") return node.label;
return node.type === "group-indexes" ? node.tableName || "" : "";
}
function prepareCreateMongoIndexDialog() {
const node = activeNode.value;
if (!canCreateMongoIndexNode(node) || !node.connectionId || !node.database) return;
resetMongoCreateIndexForm();
showCreateMongoIndexDialog.value = true;
void connectionStore
.listMongoCompletionFields(node.connectionId, node.database, mongoIndexCollectionName(node))
.then((fields) => {
const target = sidebarFormTarget.value ?? activeNode.value;
if (showCreateMongoIndexDialog.value && target.id === node.id) mongoCreateIndexFieldOptions.value = fields.map((field) => field.name);
})
.catch(() => {
// MongoDB is schemaless; users can still enter a field that was not sampled.
});
}
function addMongoCreateIndexField() {
if (!mongoCreateIndexCanAddField.value) return;
const nextId = Math.max(0, ...mongoCreateIndexForm.value.fields.map((field) => field.id)) + 1;
mongoCreateIndexForm.value.fields.push({ id: nextId, path: "", type: "1" });
}
function removeMongoCreateIndexField(id: number) {
if (mongoCreateIndexForm.value.fields.length === 1) return;
mongoCreateIndexForm.value.fields = mongoCreateIndexForm.value.fields.filter((field) => field.id !== id);
}
async function confirmCreateMongoIndex() {
const node = sidebarFormTarget.value ?? activeNode.value;
const connectionId = node.connectionId;
const database = node.database;
const collectionName = mongoIndexCollectionName(node);
if (!canCreateMongoIndexNode(node) || !connectionId || !database || !collectionName) return;
const request = buildMongoCreateIndexRequest(mongoCreateIndexForm.value);
if (!request.valid) {
mongoCreateIndexError.value = request.error === "field-duplicate" ? t("mongo.duplicateField", { field: request.field }) : t("contextMenu.createMongoIndexFieldRequired");
return;
}
mongoCreateIndexError.value = "";
await runMongoSidebarMutation({
connection: connectionStore.getConfig(connectionId),
database,
reviewText: mongoCreateIndexPreview(database, collectionName, request.keysJson, request.optionsJson),
source: t("production.sourceSidebar"),
loading: mongoCreateIndexLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: () => api.mongoCreateIndex(connectionId, database, collectionName, request.keysJson, request.optionsJson),
onSuccess: async (created) => {
showCreateMongoIndexDialog.value = false;
toast(t("contextMenu.createMongoIndexSuccess", { name: created.name, collection: collectionName }), 3000);
await refreshMongoIndexTreeAfterMutation({ ...node, tableName: collectionName });
},
onError: (error) => {
mongoCreateIndexError.value = translateBackendError(t, errorMessage(error));
},
});
}
function openCreateNacosNamespaceDialog() {
createNacosNamespaceId.value = "";
createNacosNamespaceName.value = "";
@@ -288,7 +399,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const node = sidebarDangerTarget.value ?? activeNode.value;
const connectionId = node.connectionId;
const database = node.database;
if (node.type !== "mongo-db" || !connectionId || !database) return;
if (node.type !== "mongo-db" || !connectionId || !database || !usesAnyMongoDriver(node)) return;
await runMongoSidebarMutation({
connection: connectionStore.getConfig(connectionId),
database,
@@ -296,13 +407,11 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
source: t("production.sourceSidebar"),
loading: dropDatabaseLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
await api.mongoDropDatabase(connectionId, database);
await connectionStore.loadMongoDatabases(connectionId);
},
onSuccess: () => {
execute: () => api.mongoDropDatabase(connectionId, database),
onSuccess: async () => {
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
showDropDatabaseConfirm.value = false;
await refreshMongoTreeAfterDrop(node, () => connectionStore.loadMongoDatabases(connectionId));
},
onError: toastMutationError,
});
@@ -321,28 +430,41 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
source: t("production.sourceSidebar"),
loading: dropMongoCollectionLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
await api.mongoDropCollection(connectionId, database, collectionName);
await connectionStore.loadMongoCollections(connectionId, database);
},
onSuccess: () => {
execute: () => api.mongoDropCollection(connectionId, database, collectionName),
onSuccess: async () => {
toast(t("contextMenu.dropCollectionSuccess", { name: collectionName }), 3000);
showDropMongoCollectionConfirm.value = false;
await refreshMongoTreeAfterDrop(node, async () => {
// The final collection can remove its database; if the database
// remains, refresh its preserved expanded children as well.
await connectionStore.loadMongoDatabases(connectionId);
await connectionStore.loadMongoCollections(connectionId, database);
});
},
onError: toastMutationError,
});
}
function mongoIndexesGroupNodeId(node: Pick<TreeNode, "connectionId" | "database" | "schema" | "tableName" | "label">): string | null {
if (!node.connectionId || !node.database) return null;
const tableName = node.tableName || node.label;
return node.schema ? `${node.connectionId}:${node.database}:${node.schema}:${tableName}:__indexes` : `${node.connectionId}:${node.database}:${tableName}:__indexes`;
async function refreshMongoTreeAfterDrop(node: TreeNode, refresh: () => Promise<void>) {
try {
await refresh();
} catch (error) {
connectionStore.removeTreeNode(node.id);
toast(t("contextMenu.objectDropRefreshFailed", { message: translateBackendError(t, errorMessage(error)) }), 5000);
}
}
async function refreshMongoIndexTree(node: Pick<TreeNode, "connectionId" | "database" | "schema" | "tableName" | "label">) {
const nodeId = mongoIndexesGroupNodeId(node);
if (!node.connectionId || !node.database || !nodeId) return;
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName || node.label, node.schema, nodeId);
async function refreshMongoIndexTreeAfterMutation(node: Pick<TreeNode, "connectionId" | "database" | "tableName" | "label">) {
if (!node.connectionId || !node.database) return;
try {
await refreshLoadedMongoIndexes(connectionStore, {
connectionId: node.connectionId,
database: node.database,
collection: node.tableName || node.label,
});
} catch (error) {
toast(t("contextMenu.mongoIndexRefreshFailed", { message: translateBackendError(t, errorMessage(error)) }), 5000);
}
}
async function confirmDropMongoIndex() {
@@ -360,11 +482,19 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
loading: dropMongoIndexLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(indexName), true);
await refreshMongoIndexTree(node);
try {
return await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(indexName), true);
} finally {
await refreshMongoIndexTreeAfterMutation(node);
}
},
onSuccess: () => {
toast(t("contextMenu.dropTableChildObjectSuccess", { name: indexName }), 3000);
onSuccess: (result) => {
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropTableChildObjectSuccess", { name: indexName }), 3000);
}
showDropMongoIndexConfirm.value = false;
},
onError: toastMutationError,
@@ -375,7 +505,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
const node = sidebarDangerTarget.value ?? activeNode.value;
const connectionId = node.connectionId;
const database = node.database;
if (!canMutateMongoCollectionNode(node) || !connectionId || !database) return;
if (node.type !== "mongo-collection" || !canMutateMongoIndexes(node) || !connectionId || !database) return;
const collectionName = node.label;
await runMongoSidebarMutation({
connection: connectionStore.getConfig(connectionId),
@@ -385,12 +515,19 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
loading: dropAllMongoIndexesLoading,
beforeExecute: () => connectionStore.ensureConnected(connectionId),
execute: async () => {
const result = await api.mongoDropIndexes(connectionId, database, collectionName, undefined, false);
await refreshMongoIndexTree(node);
return result;
try {
return await api.mongoDropIndexes(connectionId, database, collectionName, undefined, false);
} finally {
await refreshMongoIndexTreeAfterMutation(node);
}
},
onSuccess: (result) => {
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: collectionName }), 3000);
const failed = mongoDropIndexFailureCount(result);
if (failed > 0) {
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
} else {
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: collectionName }), 3000);
}
showDropAllMongoIndexesConfirm.value = false;
},
onError: toastMutationError,
@@ -411,9 +548,18 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
mongoIndexNameForNode,
canDropMongoIndexNode,
canDropMongoIndex,
mongoIndexDropPreview,
canDropAllMongoIndexes,
mongoIndexDropPreview,
mongoDropAllIndexesPreview: mongoDropAllIndexesPreviewForNode,
refreshMongoIndexTreeAfterMutation,
canCreateMongoIndex,
mongoIndexKeyTypes: MONGO_INDEX_KEY_TYPES,
mongoCreateIndexCanSubmit,
mongoCreateIndexCanAddField,
prepareCreateMongoIndexDialog,
addMongoCreateIndexField,
removeMongoCreateIndexField,
confirmCreateMongoIndex,
openCreateNacosNamespaceDialog,
confirmCreateNacosNamespace,
openEditNacosNamespaceDialog,
+12
View File
@@ -2131,6 +2131,15 @@ export default {
dropColumn: "Drop Column",
dropIndex: "Drop Index",
dropAllIndexes: "Drop All Indexes",
createMongoIndex: "Create Index",
createMongoIndexTitle: 'Create Index: "{collection}"',
createMongoIndexFields: "Index fields",
createMongoIndexFieldRequired: "Select or enter a field for every index row.",
mongoIndexAscending: "Ascending (1)",
mongoIndexDescending: "Descending (-1)",
createMongoIndexNamePlaceholder: "Generated automatically",
createMongoIndexNameHint: "Leave blank to let MongoDB generate the name from the fields and types.",
createMongoIndexSparse: "Sparse",
dropForeignKey: "Drop Foreign Key",
dropTrigger: "Drop Trigger",
batchDrop: "Drop selected ({count})",
@@ -2189,6 +2198,9 @@ export default {
dropFunctionSuccess: 'Function "{name}" dropped',
dropTableChildObjectSuccess: '"{name}" dropped',
dropAllIndexesSuccess: 'Dropped {count} indexes from "{name}"',
dropIndexesPartialFailure: "Dropped {success} indexes; {failed} failed.",
createMongoIndexSuccess: 'Index "{name}" created on "{collection}"',
mongoIndexRefreshFailed: "The index list could not be refreshed after the operation: {message}",
batchDropSuccess: "Dropped {count} objects",
batchEmptySuccess: "Emptied {count} tables",
batchEmptyPartialFail: "Emptied {success} tables, {failed} failed",
+13 -1
View File
@@ -2066,6 +2066,7 @@ export default withEnglishFallback({
dropView: "Eliminar vista",
dropColumn: "Eliminar columna",
dropIndex: "Eliminar índice",
dropAllIndexes: "Eliminar todos los índices",
dropForeignKey: "Eliminar clave foránea",
dropTrigger: "Eliminar disparador",
batchDrop: "Eliminar seleccionados ({count})",
@@ -2093,7 +2094,15 @@ export default withEnglishFallback({
openInSqlEditor: "Abrir en editor",
dropProcedure: "Eliminar procedimiento",
dropFunction: "Eliminar función",
dropAllIndexes: "Eliminar todos los índices",
createMongoIndex: "Crear índice",
createMongoIndexTitle: 'Crear índice: "{collection}"',
createMongoIndexFields: "Campos del índice",
createMongoIndexFieldRequired: "Selecciona o introduce un campo en cada fila.",
mongoIndexAscending: "Ascendente (1)",
mongoIndexDescending: "Descendente (-1)",
createMongoIndexNamePlaceholder: "Generado automáticamente",
createMongoIndexNameHint: "Déjalo vacío para que MongoDB genere el nombre a partir de los campos y tipos.",
createMongoIndexSparse: "Disperso",
batchDropIndexes: "Eliminar índices ({count})",
confirmDropViewTitle: "Eliminar vista",
confirmDropViewMessage: '¿Seguro que deseas eliminar la vista "{name}"?',
@@ -2123,6 +2132,9 @@ export default withEnglishFallback({
dropFunctionSuccess: 'Función "{name}" eliminada',
dropTableChildObjectSuccess: '"{name}" eliminado',
dropAllIndexesSuccess: 'Se eliminaron {count} índices de "{name}"',
dropIndexesPartialFailure: "Se eliminaron {success} índices; {failed} fallaron.",
createMongoIndexSuccess: 'Índice "{name}" creado en "{collection}"',
mongoIndexRefreshFailed: "No se pudo actualizar la lista de índices después de la operación: {message}",
batchDropSuccess: "{count} objetos eliminados",
batchEmptySuccess: "{count} tablas vaciadas",
batchEmptyPartialFail: "Se vaciaron {success} tablas; {failed} fallaron",
+12
View File
@@ -2065,6 +2065,15 @@ export default withEnglishFallback({
dropColumn: "Elimina Colonna",
dropIndex: "Elimina Indice",
dropAllIndexes: "Elimina tutti gli indici",
createMongoIndex: "Crea indice",
createMongoIndexTitle: 'Crea indice: "{collection}"',
createMongoIndexFields: "Campi dell'indice",
createMongoIndexFieldRequired: "Seleziona o inserisci un campo per ogni riga.",
mongoIndexAscending: "Crescente (1)",
mongoIndexDescending: "Decrescente (-1)",
createMongoIndexNamePlaceholder: "Generato automaticamente",
createMongoIndexNameHint: "Lascia vuoto per fare generare il nome a MongoDB dai campi e dai tipi.",
createMongoIndexSparse: "Sparso",
dropForeignKey: "Elimina Chiave Esterna",
dropTrigger: "Elimina Trigger",
batchDrop: "Elimina selezionati ({count})",
@@ -2121,6 +2130,9 @@ export default withEnglishFallback({
dropFunctionSuccess: 'Funzione "{name}" eliminata',
dropTableChildObjectSuccess: '"{name}" eliminato',
dropAllIndexesSuccess: 'Eliminati {count} indici da "{name}"',
dropIndexesPartialFailure: "Eliminati {success} indici; {failed} non riusciti.",
createMongoIndexSuccess: 'Indice "{name}" creato in "{collection}"',
mongoIndexRefreshFailed: "Non è stato possibile aggiornare l'elenco degli indici dopo l'operazione: {message}",
batchDropSuccess: "Eliminati {count} oggetti",
batchEmptySuccess: "Svuotate {count} tabelle",
batchEmptyPartialFail: "Svuotate {success} tabelle, {failed} non riuscite",
+13 -1
View File
@@ -2087,6 +2087,7 @@ export default withEnglishFallback({
dropView: "ビューを削除",
dropColumn: "列を削除",
dropIndex: "インデックスを削除",
dropAllIndexes: "すべてのインデックスを削除",
dropForeignKey: "外部キーを削除",
dropTrigger: "トリガーを削除",
batchDrop: "選択を削除({count}件)",
@@ -2114,7 +2115,15 @@ export default withEnglishFallback({
openInSqlEditor: "エディタで開く",
dropProcedure: "プロシージャを削除",
dropFunction: "関数を削除",
dropAllIndexes: "すべてのインデックスを削除",
createMongoIndex: "インデックスを作成",
createMongoIndexTitle: "インデックスを作成: 「{collection}」",
createMongoIndexFields: "インデックスフィールド",
createMongoIndexFieldRequired: "各行でフィールドを選択または入力してください。",
mongoIndexAscending: "昇順 (1)",
mongoIndexDescending: "降順 (-1)",
createMongoIndexNamePlaceholder: "自動生成",
createMongoIndexNameHint: "空欄の場合、MongoDB がフィールドとタイプから名前を生成します。",
createMongoIndexSparse: "スパース",
batchDropIndexes: "インデックスを削除 ({count})",
confirmDropViewTitle: "ビューを削除",
confirmDropViewMessage: "本当にビュー「{name}」を削除しますか?",
@@ -2144,6 +2153,9 @@ export default withEnglishFallback({
dropFunctionSuccess: "関数「{name}」を削除しました",
dropTableChildObjectSuccess: "「{name}」を削除しました",
dropAllIndexesSuccess: "「{name}」から {count} 個のインデックスを削除しました",
dropIndexesPartialFailure: "{success} 個のインデックスを削除し、{failed} 個は失敗しました。",
createMongoIndexSuccess: "「{collection}」にインデックス「{name}」を作成しました",
mongoIndexRefreshFailed: "操作後にインデックス一覧を更新できませんでした: {message}",
batchDropSuccess: "{count}個のオブジェクトを削除しました",
batchEmptySuccess: "{count}テーブルを空にしました",
batchEmptyPartialFail: "{success}テーブルを空にし、{failed}テーブルは失敗しました",
+12
View File
@@ -2089,6 +2089,15 @@ export default withEnglishFallback({
dropColumn: "컬럼 삭제",
dropIndex: "인덱스 삭제",
dropAllIndexes: "모든 인덱스 삭제",
createMongoIndex: "인덱스 생성",
createMongoIndexTitle: '인덱스 생성: "{collection}"',
createMongoIndexFields: "인덱스 필드",
createMongoIndexFieldRequired: "각 행에서 필드를 선택하거나 입력하세요.",
mongoIndexAscending: "오름차순 (1)",
mongoIndexDescending: "내림차순 (-1)",
createMongoIndexNamePlaceholder: "자동 생성",
createMongoIndexNameHint: "비워 두면 MongoDB가 필드와 유형으로 이름을 생성합니다.",
createMongoIndexSparse: "희소",
dropForeignKey: "외래 키 삭제",
dropTrigger: "트리거 삭제",
batchDrop: "선택 항목 삭제 ({count})",
@@ -2147,6 +2156,9 @@ export default withEnglishFallback({
dropFunctionSuccess: '함수 "{name}" 삭제됨',
dropTableChildObjectSuccess: '"{name}" 삭제됨',
dropAllIndexesSuccess: '"{name}"에서 인덱스 {count}개 삭제됨',
dropIndexesPartialFailure: "인덱스 {success}개를 삭제했고 {failed}개는 실패했습니다.",
createMongoIndexSuccess: '"{collection}"에 인덱스 "{name}" 생성됨',
mongoIndexRefreshFailed: "작업 후 인덱스 목록을 새로 고칠 수 없습니다: {message}",
batchDropSuccess: "객체 {count}개 삭제됨",
batchEmptySuccess: "테이블 {count}개 비움",
batchEmptyPartialFail: "테이블 {success}개 비움, {failed}개 실패",
+13 -1
View File
@@ -2066,6 +2066,7 @@ export default withEnglishFallback({
dropView: "Remover Visão",
dropColumn: "Remover Coluna",
dropIndex: "Remover Índice",
dropAllIndexes: "Remover todos os índices",
dropForeignKey: "Remover Chave Estrangeira",
dropTrigger: "Remover Gatilho",
batchDrop: "Remover selecionados ({count})",
@@ -2093,7 +2094,15 @@ export default withEnglishFallback({
openInSqlEditor: "Abrir no Editor",
dropProcedure: "Remover Procedimento",
dropFunction: "Remover Função",
dropAllIndexes: "Remover todos os índices",
createMongoIndex: "Criar índice",
createMongoIndexTitle: 'Criar índice: "{collection}"',
createMongoIndexFields: "Campos do índice",
createMongoIndexFieldRequired: "Selecione ou informe um campo em cada linha.",
mongoIndexAscending: "Ascendente (1)",
mongoIndexDescending: "Descendente (-1)",
createMongoIndexNamePlaceholder: "Gerado automaticamente",
createMongoIndexNameHint: "Deixe em branco para o MongoDB gerar o nome com base nos campos e tipos.",
createMongoIndexSparse: "Esparso",
batchDropIndexes: "Remover índices ({count})",
confirmDropViewTitle: "Remover Visão",
confirmDropViewMessage: 'Tem certeza de que deseja remover a visão "{name}"?',
@@ -2123,6 +2132,9 @@ export default withEnglishFallback({
dropFunctionSuccess: 'Função "{name}" removida',
dropTableChildObjectSuccess: '"{name}" removido',
dropAllIndexesSuccess: 'Removidos {count} índices de "{name}"',
dropIndexesPartialFailure: "{success} índices removidos; {failed} falharam.",
createMongoIndexSuccess: 'Índice "{name}" criado em "{collection}"',
mongoIndexRefreshFailed: "Não foi possível atualizar a lista de índices após a operação: {message}",
batchDropSuccess: "{count} objetos removidos",
batchEmptySuccess: "{count} tabelas esvaziadas",
batchEmptyPartialFail: "{success} tabelas esvaziadas; {failed} falharam",
+12
View File
@@ -2132,6 +2132,15 @@ export default withEnglishFallback({
dropColumn: "删除字段",
dropIndex: "删除索引",
dropAllIndexes: "删除全部索引",
createMongoIndex: "新建索引",
createMongoIndexTitle: "创建索引:「{collection}」",
createMongoIndexFields: "索引字段",
createMongoIndexFieldRequired: "请为每一行选择或输入字段。",
mongoIndexAscending: "升序 (1)",
mongoIndexDescending: "降序 (-1)",
createMongoIndexNamePlaceholder: "自动生成",
createMongoIndexNameHint: "留空时由 MongoDB 根据字段和类型自动生成名称。",
createMongoIndexSparse: "稀疏",
dropForeignKey: "删除外键",
dropTrigger: "删除触发器",
batchDrop: "删除所选({count})",
@@ -2190,6 +2199,9 @@ export default withEnglishFallback({
dropFunctionSuccess: "函数「{name}」已删除",
dropTableChildObjectSuccess: "「{name}」已删除",
dropAllIndexesSuccess: "已从「{name}」删除 {count} 个索引",
dropIndexesPartialFailure: "已删除 {success} 个索引,{failed} 个失败。",
createMongoIndexSuccess: "已在「{collection}」创建索引「{name}」",
mongoIndexRefreshFailed: "索引操作后无法刷新索引列表:{message}",
batchDropSuccess: "已删除 {count} 个对象",
batchEmptySuccess: "已清空 {count} 张表",
batchEmptyPartialFail: "已清空 {success} 张表,{failed} 张失败",
+13 -1
View File
@@ -2065,6 +2065,7 @@ export default withEnglishFallback({
dropView: "刪除檢視",
dropColumn: "刪除欄位",
dropIndex: "刪除索引",
dropAllIndexes: "刪除全部索引",
dropForeignKey: "刪除外鍵",
dropTrigger: "刪除觸發器",
batchDrop: "刪除所選({count})",
@@ -2092,7 +2093,15 @@ export default withEnglishFallback({
openInSqlEditor: "在編輯器中開啟",
dropProcedure: "刪除預存程序",
dropFunction: "刪除函式",
dropAllIndexes: "刪除全部索引",
createMongoIndex: "新增索引",
createMongoIndexTitle: "建立索引:「{collection}」",
createMongoIndexFields: "索引欄位",
createMongoIndexFieldRequired: "請為每一列選擇或輸入欄位。",
mongoIndexAscending: "升冪 (1)",
mongoIndexDescending: "降冪 (-1)",
createMongoIndexNamePlaceholder: "自動產生",
createMongoIndexNameHint: "留空時由 MongoDB 根據欄位和類型自動產生名稱。",
createMongoIndexSparse: "稀疏",
batchDropIndexes: "刪除索引({count})",
confirmDropViewTitle: "刪除檢視",
confirmDropViewMessage: "確定要刪除檢視「{name}」嗎?",
@@ -2122,6 +2131,9 @@ export default withEnglishFallback({
dropFunctionSuccess: "函式「{name}」已刪除",
dropTableChildObjectSuccess: "「{name}」已刪除",
dropAllIndexesSuccess: "已從「{name}」刪除 {count} 個索引",
dropIndexesPartialFailure: "已刪除 {success} 個索引,{failed} 個失敗。",
createMongoIndexSuccess: "已在「{collection}」建立索引「{name}」",
mongoIndexRefreshFailed: "索引操作後無法重新整理索引清單:{message}",
batchDropSuccess: "已刪除 {count} 個物件",
batchEmptySuccess: "已清空 {count} 張資料表",
batchEmptyPartialFail: "已清空 {success} 張資料表,{failed} 張失敗",
@@ -140,6 +140,7 @@ describe("database namespace creation", () => {
expect(connectionNamespaceCreationTarget({ db_type: "sqlite" })).toBe("attach");
expect(connectionNamespaceCreationTarget({ db_type: "mongodb" })).toBe("special");
expect(connectionNamespaceCreationTarget({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBeNull();
expect(connectionNamespaceCreationTarget({ db_type: "mongodb", driver_profile: "legacy" })).toBeNull();
});
it("hides persistent SQLite attachment for memory and SQLCipher connections", () => {
+2 -1
View File
@@ -99,6 +99,7 @@ import type {
DocumentQueryResult,
MongoDocumentResult,
MongoCollectionStatsResult,
MongoDropIndexesResult,
MongoGridFsBucketInfo,
HistoryEntry,
HistorySearchRequest,
@@ -3105,7 +3106,7 @@ export async function mongoCreateIndex(connectionId: string, database: string, c
});
}
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> {
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<MongoDropIndexesResult> {
return post("/api/mongo/drop-indexes", {
connectionId,
database,
+12 -1
View File
@@ -2796,6 +2796,17 @@ export interface MongoCollectionStatsResult {
nindexes: unknown;
}
export interface MongoDropIndexFailure {
name: string;
message: string;
}
export interface MongoDropIndexesResult {
dropped_names: string[];
affected_rows: number;
failures?: MongoDropIndexFailure[];
}
export interface MongoGridFsFileInfo {
id: string;
filename?: string;
@@ -3043,7 +3054,7 @@ export async function mongoCreateIndex(connectionId: string, database: string, c
});
}
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<{ dropped_names: string[]; affected_rows: number }> {
export async function mongoDropIndexes(connectionId: string, database: string, collection: string, indexesJson?: string, single = false): Promise<MongoDropIndexesResult> {
return invoke("mongo_drop_indexes", {
connectionId,
database,
@@ -1,4 +1,5 @@
import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
export type DatabaseNamespaceCreationTarget = "database" | "schema" | "attach" | "special";
@@ -87,7 +88,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null {
if (!connection || connection.read_only) return null;
if (connection.db_type === "mongodb" && connection.driver_profile === "mongodb-legacy") return null;
if (connection.db_type === "mongodb" && isMongoLegacyDriverProfile(connection.driver_profile)) return null;
if (connection.db_type === "sqlite" && (connection.host?.trim().toLowerCase() === ":memory:" || Boolean(connection.password))) {
return null;
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { isMongoLegacyDriverProfile, mongoCollectionSupportsIndexes, supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
describe("MongoDB driver mutation capabilities", () => {
it("keeps index and drop mutations available through both native and Legacy drivers", () => {
expect(supportsMongoAllDriverMutations({ db_type: "mongodb", driver_profile: "mongodb" })).toBe(true);
expect(supportsMongoAllDriverMutations({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBe(true);
});
it("does not offer mutation actions on a read-only MongoDB connection", () => {
const readOnly = { db_type: "mongodb", driver_profile: "mongodb-legacy", read_only: true } as const;
expect(supportsMongoAllDriverMutations(readOnly)).toBe(false);
expect(supportsMongoIndexMutations(readOnly, "collection")).toBe(false);
expect(supportsNativeMongoDriverMutations({ ...readOnly, driver_profile: "mongodb" })).toBe(false);
});
it("keeps index metadata visible on read-only collections", () => {
expect(mongoCollectionSupportsIndexes("collection")).toBe(true);
expect(mongoCollectionSupportsIndexes("timeseries")).toBe(true);
expect(mongoCollectionSupportsIndexes(undefined)).toBe(true);
expect(mongoCollectionSupportsIndexes(" VIEW ")).toBe(false);
});
it("allows index mutations for collections and time-series collections, but not views", () => {
const native = { db_type: "mongodb", driver_profile: "mongodb" } as const;
const legacy = { db_type: "mongodb", driver_profile: "mongodb-legacy" } as const;
expect(supportsMongoIndexMutations(native, "collection")).toBe(true);
expect(supportsMongoIndexMutations(legacy, "timeseries")).toBe(true);
expect(supportsMongoIndexMutations(legacy)).toBe(true);
expect(supportsMongoIndexMutations(native, " VIEW ")).toBe(false);
});
it("reserves native-only mutations for the native MongoDB driver", () => {
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "mongodb" })).toBe(true);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: undefined })).toBe(true);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBe(false);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "legacy" })).toBe(false);
expect(supportsNativeMongoDriverMutations({ db_type: "mongodb", driver_profile: "MongoDB_Legacy" })).toBe(false);
});
it("recognizes historical Legacy profile spellings", () => {
expect(isMongoLegacyDriverProfile("mongodb-legacy")).toBe(true);
expect(isMongoLegacyDriverProfile(" mongodb_legacy ")).toBe(true);
expect(isMongoLegacyDriverProfile("legacy")).toBe(true);
expect(isMongoLegacyDriverProfile("mongodb")).toBe(false);
});
it("does not grant MongoDB mutations to another connection type", () => {
expect(supportsMongoAllDriverMutations({ db_type: "postgres", driver_profile: undefined })).toBe(false);
expect(supportsNativeMongoDriverMutations(undefined)).toBe(false);
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import type { TreeNode } from "@/types/database";
import { findLoadedMongoIndexesGroup, refreshLoadedMongoIndexes } from "../mongoIndexMetadata";
const scope = { connectionId: "conn-1", database: "app", collection: "users" };
function indexesGroup(isExpanded = false): TreeNode {
return {
id: "conn-1:app:users:__indexes",
label: "tree.indexes",
type: "group-indexes",
connectionId: "conn-1",
database: "app",
tableName: "users",
isExpanded,
children: [],
};
}
describe("Mongo index metadata refresh", () => {
it("finds groups retained under hidden tree children", () => {
const group = indexesGroup();
const root = { id: "root", label: "root", type: "connection", hiddenChildren: [group] } as TreeNode;
expect(findLoadedMongoIndexesGroup([root], scope)).toBe(group);
});
it("refreshes a loaded group without expanding a collapsed tree node", async () => {
const group = indexesGroup(false);
const loadIndexes = vi.fn(async () => {
group.isExpanded = true;
});
await expect(refreshLoadedMongoIndexes({ treeNodes: [group], loadIndexes }, scope)).resolves.toBe(true);
expect(loadIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, group.id, undefined);
expect(group.isExpanded).toBe(false);
});
it("restores expansion state and preserves a refresh failure", async () => {
const group = indexesGroup(false);
const failure = new Error("metadata unavailable");
const loadIndexes = vi.fn(async () => {
group.isExpanded = true;
throw failure;
});
await expect(refreshLoadedMongoIndexes({ treeNodes: [group], loadIndexes }, scope)).rejects.toBe(failure);
expect(group.isExpanded).toBe(false);
});
});
@@ -0,0 +1,28 @@
import type { ConnectionConfig } from "@/types/database";
type MongoConnectionProfile = Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only">;
/** Accept historical Legacy profile spellings while persisted configs are normalized. */
export function isMongoLegacyDriverProfile(driverProfile?: string): boolean {
const profile = driverProfile?.trim().toLowerCase();
return profile === "mongodb-legacy" || profile === "mongodb_legacy" || profile === "legacy";
}
/** Capabilities implemented by both the native driver and the Legacy Agent. */
export function supportsMongoAllDriverMutations(connection?: MongoConnectionProfile): boolean {
return connection?.db_type === "mongodb" && !connection.read_only;
}
/** MongoDB views do not own indexes; collections and time-series collections do. */
export function mongoCollectionSupportsIndexes(collectionKind?: string): boolean {
return collectionKind?.trim().toLowerCase() !== "view";
}
export function supportsMongoIndexMutations(connection?: MongoConnectionProfile, collectionKind?: string): boolean {
return supportsMongoAllDriverMutations(connection) && mongoCollectionSupportsIndexes(collectionKind);
}
/** Capabilities which require the native Rust MongoDB driver. */
export function supportsNativeMongoDriverMutations(connection?: MongoConnectionProfile): boolean {
return supportsMongoAllDriverMutations(connection) && !isMongoLegacyDriverProfile(connection?.driver_profile);
}
@@ -0,0 +1,40 @@
import type { TreeNode } from "@/types/database";
type MongoIndexMetadataStore = {
treeNodes: TreeNode[];
loadIndexes: (connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) => Promise<void>;
};
export type MongoIndexMetadataScope = {
connectionId: string;
database: string;
collection: string;
};
export function findLoadedMongoIndexesGroup(nodes: readonly TreeNode[], scope: MongoIndexMetadataScope): TreeNode | undefined {
const pending = [...nodes];
const visited = new Set<TreeNode>();
while (pending.length > 0) {
const node = pending.pop()!;
if (visited.has(node)) continue;
visited.add(node);
if (node.type === "group-indexes" && node.connectionId === scope.connectionId && node.database === scope.database && node.tableName === scope.collection) return node;
pending.push(...(node.children ?? []), ...(node.hiddenChildren ?? []));
}
return undefined;
}
/** Refresh an existing sidebar index group without changing its expanded state. */
export async function refreshLoadedMongoIndexes(store: MongoIndexMetadataStore, scope: MongoIndexMetadataScope): Promise<boolean> {
const group = findLoadedMongoIndexesGroup(store.treeNodes, scope);
if (!group) return false;
const wasExpanded = group.isExpanded;
try {
await store.loadIndexes(scope.connectionId, scope.database, scope.collection, group.schema, group.id, group.catalog);
} finally {
const currentGroup = findLoadedMongoIndexesGroup(store.treeNodes, scope);
if (currentGroup) currentGroup.isExpanded = wasExpanded;
}
return true;
}
@@ -756,7 +756,15 @@ export function mongoCreateIndexToQueryResult(name: string, executionTimeMs: num
};
}
export function mongoDroppedIndexesToQueryResult(names: string[], executionTimeMs: number): QueryResult {
export function mongoDroppedIndexesToQueryResult(names: string[], executionTimeMs: number, failures: Array<{ name: string; message: string }> = []): QueryResult {
if (failures.length > 0) {
return {
columns: ["name", "status", "message"],
rows: [...names.map((name) => [name, "dropped", null] as [string, string, null]), ...failures.map((failure) => [failure.name, "failed", failure.message])],
affected_rows: names.length,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
return {
columns: ["name"],
rows: names.map((name) => [name]),
@@ -1,5 +1,23 @@
import { describe, expect, it } from "vitest";
import { isRenamableMongoCollection, mongoCollectionKindFromNode, mongoDropAllIndexesPreview, mongoDropCollectionPreview, mongoDropIndexPreview, mongoRenameCollectionPreview, toMongoCollectionKind } from "../mongoCollectionMutation";
import {
buildMongoCreateIndexRequest,
isProtectedMongoIndex,
isRenamableMongoCollection,
mongoCollectionKindFromNode,
mongoCollectionTableTypeFromNode,
mongoCreateIndexPreview,
mongoDropCollectionPreview,
mongoDropAllIndexesPreview,
mongoDropIndexFailureCount,
mongoDropIndexPreview,
mongoRenameCollectionPreview,
toMongoCollectionKind,
type MongoCreateIndexForm,
} from "../mongoCollectionMutation";
function indexForm(fields: MongoCreateIndexForm["fields"], options: Partial<Omit<MongoCreateIndexForm, "fields">> = {}): MongoCreateIndexForm {
return { name: "", unique: false, sparse: false, ...options, fields };
}
describe("isRenamableMongoCollection", () => {
it("allows ordinary collections and defaults", () => {
@@ -21,6 +39,12 @@ describe("mongoCollectionKindFromNode", () => {
expect(mongoCollectionKindFromNode({ meta: { collectionKind: "collection" } })).toBe("collection");
expect(mongoCollectionKindFromNode({})).toBe("collection");
});
it("maps collection kinds to data-tab table types", () => {
expect(mongoCollectionTableTypeFromNode({ meta: { collectionKind: "collection" } })).toBe("TABLE");
expect(mongoCollectionTableTypeFromNode({ meta: { collectionKind: "view" } })).toBe("VIEW");
expect(mongoCollectionTableTypeFromNode({ meta: { collectionKind: "timeseries" } })).toBe("TIMESERIES");
});
});
describe("toMongoCollectionKind", () => {
@@ -32,6 +56,14 @@ describe("toMongoCollectionKind", () => {
});
});
describe("isProtectedMongoIndex", () => {
it("protects the default index by name or primary metadata", () => {
expect(isProtectedMongoIndex({ name: "_id_", is_primary: false })).toBe(true);
expect(isProtectedMongoIndex({ name: "unexpected", is_primary: true })).toBe(true);
expect(isProtectedMongoIndex({ name: "email_1", is_primary: false })).toBe(false);
});
});
describe("mongo shell previews", () => {
it("preserves identifier whitespace in rename preview", () => {
expect(mongoRenameCollectionPreview("app", " users ", " renamed ")).toBe('db.getSiblingDB("app").getCollection(" users ").renameCollection(" renamed ")');
@@ -42,4 +74,66 @@ describe("mongo shell previews", () => {
expect(mongoDropIndexPreview("app", "users", "idx_name")).toBe('db.getSiblingDB("app").getCollection("users").dropIndex("idx_name")');
expect(mongoDropAllIndexesPreview("app", "users")).toBe('db.getSiblingDB("app").getCollection("users").dropIndexes()');
});
it("counts per-index failures in partial batch results", () => {
expect(mongoDropIndexFailureCount({})).toBe(0);
expect(mongoDropIndexFailureCount({ failures: [{ name: "missing_1", message: "index not found" }] })).toBe(1);
});
it("builds a create-index request and shell preview from the visual form", () => {
const request = buildMongoCreateIndexRequest(
indexForm(
[
{ id: 1, path: "email", type: "1" },
{ id: 2, path: "createdAt", type: "-1" },
],
{ name: "email_created_at", unique: true, sparse: true },
),
);
expect(request).toMatchObject({
valid: true,
keysJson: '{"email":1,"createdAt":-1}',
optionsJson: '{"name":"email_created_at","unique":true,"sparse":true}',
});
if (!request.valid) throw new Error("expected valid index form");
expect(mongoCreateIndexPreview("app", "users", request.keysJson, request.optionsJson)).toBe('db.getSiblingDB("app").getCollection("users").createIndex({"email":1,"createdAt":-1}, {"name":"email_created_at","unique":true,"sparse":true})');
});
it("keeps visual compound-field order, including integer-like names", () => {
const request = buildMongoCreateIndexRequest(
indexForm([
{ id: 1, path: "10", type: "1" },
{ id: 2, path: "2", type: "-1" },
]),
);
if (!request.valid) throw new Error("expected valid index form");
expect(request.optionsJson).toBeUndefined();
expect(mongoCreateIndexPreview("app", "events", request.keysJson, request.optionsJson)).toBe('db.getSiblingDB("app").getCollection("events").createIndex({"10":1,"2":-1})');
});
it("serializes MongoDB-specific key types without exposing JSON inputs", () => {
const request = buildMongoCreateIndexRequest(
indexForm([
{ id: 1, path: "content", type: "text" },
{ id: 2, path: "location", type: "2dsphere" },
]),
);
expect(request).toEqual({ valid: true, keysJson: '{"content":"text","location":"2dsphere"}', optionsJson: undefined });
});
it("requires every field and rejects duplicate field paths", () => {
expect(buildMongoCreateIndexRequest(indexForm([]))).toEqual({ valid: false, error: "field-required" });
expect(buildMongoCreateIndexRequest(indexForm([{ id: 1, path: " ", type: "1" }]))).toEqual({ valid: false, error: "field-required" });
expect(
buildMongoCreateIndexRequest(
indexForm([
{ id: 1, path: "email", type: "1" },
{ id: 2, path: "email", type: "-1" },
]),
),
).toEqual({ valid: false, error: "field-duplicate", field: "email" });
});
});
@@ -65,6 +65,31 @@ describe("runMongoSidebarMutation", () => {
expect(onSuccess).toHaveBeenCalledWith(result);
});
it("waits for asynchronous success work before clearing loading", async () => {
const loading = ref(false);
let releaseSuccess: () => void = () => {};
const successWork = new Promise<void>((resolve) => {
releaseSuccess = resolve;
});
const onSuccess = vi.fn(async () => successWork);
const pending = runMongoSidebarMutation({
connection: connection(),
database: "app",
reviewText: 'db.getCollection("users").createIndex({"email":1})',
source: "Object tree",
loading,
execute: async () => ({ name: "email_1" }),
onSuccess,
});
await vi.waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
expect(loading.value).toBe(true);
releaseSuccess();
await pending;
expect(loading.value).toBe(false);
});
it("does not execute or call onSuccess when production confirmation is cancelled", async () => {
const loading = ref(false);
const execute = vi.fn().mockResolvedValue(undefined);
@@ -1,5 +1,34 @@
import type { MongoCollectionKind, TreeNode } from "@/types/database";
export const MONGO_INDEX_KEY_TYPES = ["1", "-1", "text", "hashed", "2dsphere", "2d"] as const;
export type MongoIndexKeyType = (typeof MONGO_INDEX_KEY_TYPES)[number];
export interface MongoCreateIndexField {
id: number;
path: string;
type: MongoIndexKeyType;
}
export interface MongoCreateIndexForm {
name: string;
fields: MongoCreateIndexField[];
unique: boolean;
sparse: boolean;
}
export type MongoCreateIndexRequest =
| {
valid: true;
keysJson: string;
optionsJson?: string;
}
| {
valid: false;
error: "field-required" | "field-duplicate";
field?: string;
};
/**
* MongoDB only supports renameCollection for ordinary, non-system collections.
* Views, time-series collections, and reserved system namespaces must not expose a rename action.
@@ -17,6 +46,11 @@ export function mongoCollectionKindFromNode(node: Pick<TreeNode, "meta">): Mongo
return "collection";
}
export function mongoCollectionTableTypeFromNode(node: Pick<TreeNode, "meta">): "TABLE" | "VIEW" | "TIMESERIES" {
const kind = mongoCollectionKindFromNode(node);
return kind === "view" ? "VIEW" : kind === "timeseries" ? "TIMESERIES" : "TABLE";
}
export function toMongoCollectionKind(kind?: string | null): MongoCollectionKind {
const normalized = (kind || "collection").toLowerCase();
if (normalized === "view") return "view";
@@ -43,3 +77,44 @@ export function mongoDropIndexPreview(database: string, collection: string, inde
export function mongoDropAllIndexesPreview(database: string, collection: string): string {
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).dropIndexes()`;
}
export function mongoDropIndexFailureCount(result: { failures?: readonly unknown[] }): number {
return result.failures?.length ?? 0;
}
/** MongoDB always protects its default _id index, even when metadata is incomplete. */
export function isProtectedMongoIndex(index: { name: string; is_primary?: boolean }): boolean {
return index.name === "_id_" || !!index.is_primary;
}
/** Convert the visual form into the driver's JSON transport format. */
export function buildMongoCreateIndexRequest(form: MongoCreateIndexForm): MongoCreateIndexRequest {
const fields = form.fields.map((field) => ({ ...field, path: field.path.trim() }));
if (fields.length === 0 || fields.some((field) => !field.path)) return { valid: false, error: "field-required" };
const seen = new Set<string>();
for (const field of fields) {
if (seen.has(field.path)) return { valid: false, error: "field-duplicate", field: field.path };
seen.add(field.path);
}
// Build the object text directly so compound indexes retain their visual row order,
// including when a field name looks like an integer.
const keysJson = `{${fields.map((field) => `${JSON.stringify(field.path)}:${JSON.stringify(field.type === "1" ? 1 : field.type === "-1" ? -1 : field.type)}`).join(",")}}`;
const options = {
...(form.name.trim() ? { name: form.name.trim() } : {}),
...(form.unique ? { unique: true } : {}),
...(form.sparse ? { sparse: true } : {}),
};
const optionsJson = Object.keys(options).length ? JSON.stringify(options) : undefined;
return { valid: true, keysJson, optionsJson };
}
/**
* Keep the preview and production confirmation byte-for-byte aligned with
* the JSON passed to the backend. Re-serializing parsed JSON could reorder a
* compound key or round a large numeric value before the user reviews it.
*/
export function mongoCreateIndexPreview(database: string, collection: string, keysJson: string, optionsJson?: string): string {
const args = [keysJson, ...(optionsJson ? [optionsJson] : [])];
return `db.getSiblingDB(${JSON.stringify(database)}).getCollection(${JSON.stringify(collection)}).createIndex(${args.join(", ")})`;
}
@@ -11,7 +11,7 @@ export type RunMongoSidebarMutationOptions<T> = {
/** Runs after production confirmation and after loading is set (e.g. ensureConnected). */
beforeExecute?: () => Promise<void>;
execute: () => Promise<T>;
onSuccess: (result: T) => void;
onSuccess: (result: T) => void | Promise<void>;
onError?: (error: unknown) => void;
};
@@ -41,10 +41,13 @@ export async function runMongoSidebarMutation<T>(options: RunMongoSidebarMutatio
});
// Cancel only: guard returns undefined when the user declines production confirmation.
if (executed === undefined) return;
options.onSuccess(executed.result);
await options.onSuccess(executed.result);
} catch (error: unknown) {
options.onError?.(error);
} finally {
options.loading.value = false;
}
}
// DataGrid and sidebar mutations intentionally share the same production-gated lifecycle.
export const runMongoMutation = runMongoSidebarMutation;
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
describe("tableMetadataCapabilities", () => {
it("exposes only collection indexes for MongoDB table information", () => {
expect(getTableMetadataCapabilities("mongodb")).toEqual({
columns: false,
indexes: true,
foreignKeys: false,
triggers: false,
ddl: false,
});
});
});
@@ -17,6 +17,12 @@ const defaultCapabilities: TableMetadataCapabilities = {
};
const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabilities>>> = {
mongodb: {
columns: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
clickhouse: {
foreignKeys: false,
triggers: false,
@@ -0,0 +1,121 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, MongoCollectionKind, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function mongoConnection(): ConnectionConfig {
return {
id: "mongo-1",
name: "MongoDB",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
} as ConnectionConfig;
}
function findNode(nodes: TreeNode[], id: string): TreeNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const child = node.children && findNode(node.children, id);
if (child) return child;
}
return undefined;
}
async function setup(kind: MongoCollectionKind) {
const api = {
listIndexes: vi.fn().mockResolvedValue([{ name: "email_1", columns: ["email"], is_primary: false, is_unique: false }]),
};
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => api);
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const config = mongoConnection();
const collectionId = `${config.id}:app:reports`;
store.connections = [config];
store.connectedIds.add(config.id);
store.treeNodes = [
{
id: config.id,
label: config.name,
type: "connection",
connectionId: config.id,
isExpanded: true,
children: [
{
id: collectionId,
label: "reports",
type: "mongo-collection",
connectionId: config.id,
database: "app",
meta: { collectionKind: kind },
isExpanded: false,
children: [],
},
],
},
];
return { api, config, store, collectionId };
}
describe("connectionStore MongoDB collection groups", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("does not expose an Indexes group or issue listIndexes for a view", async () => {
const { api, config, store, collectionId } = await setup("view");
await store.loadTableGroups(config.id, "app", "reports", undefined, collectionId);
const collection = findNode(store.treeNodes, collectionId)!;
expect(collection.children?.some((node) => node.type === "group-indexes")).toBe(false);
// A stale tree node from an earlier metadata load must also remain safe.
const indexGroup: TreeNode = {
id: `${collectionId}:__indexes`,
label: "tree.indexes",
type: "group-indexes",
connectionId: config.id,
database: "app",
tableName: "reports",
meta: { collectionKind: "view" },
isExpanded: false,
children: [],
};
collection.children = [indexGroup];
await store.loadIndexes(config.id, "app", "reports", undefined, indexGroup.id);
expect(api.listIndexes).not.toHaveBeenCalled();
expect(findNode(store.treeNodes, indexGroup.id)).toMatchObject({ children: [], isExpanded: true });
});
it("keeps the Indexes group available for ordinary collections", async () => {
const { api, config, store, collectionId } = await setup("collection");
await store.loadTableGroups(config.id, "app", "reports", undefined, collectionId);
const indexGroup = findNode(store.treeNodes, `${collectionId}:__indexes`)!;
expect(indexGroup.type).toBe("group-indexes");
await store.loadIndexes(config.id, "app", "reports", undefined, indexGroup.id);
expect(api.listIndexes).toHaveBeenCalledOnce();
expect(indexGroup.children?.[0]).toMatchObject({ type: "index", label: "email_1 (email)" });
});
});
@@ -0,0 +1,120 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function mongoConnection(): ConnectionConfig {
return {
id: "mongo-legacy-fallback",
name: "MongoDB",
db_type: "mongodb",
driver_profile: "mongodb",
driver_label: "MongoDB",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "admin",
} as ConnectionConfig;
}
describe("connectionStore MongoDB Legacy fallback", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("uses the persisted Web fallback profile to hide native-only actions", async () => {
const config = mongoConnection();
const legacyConfig: ConnectionConfig = {
...config,
driver_profile: "mongodb-legacy",
driver_label: "MongoDB (Legacy)",
};
const loadConnections = vi.fn().mockResolvedValue([legacyConfig]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
loadConnections,
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
store.connections[0] = {
...store.connections[0],
note: "Updated while connecting",
};
await store.connect(config);
expect(loadConnections).toHaveBeenCalledOnce();
expect(store.getConfig(config.id)?.driver_profile).toBe("mongodb-legacy");
expect(store.getConfig(config.id)?.driver_label).toBe("MongoDB (Legacy)");
expect(store.getConfig(config.id)?.note).toBe("Updated while connecting");
});
it("does not apply a stale fallback after the connection config changes", async () => {
const config = mongoConnection();
const legacyConfig: ConnectionConfig = {
...config,
driver_profile: "mongodb-legacy",
driver_label: "MongoDB (Legacy)",
};
let resolveLoadConnections!: (configs: ConnectionConfig[]) => void;
const loadConnections = vi.fn().mockImplementation(
() =>
new Promise<ConnectionConfig[]>((resolve) => {
resolveLoadConnections = resolve;
}),
);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
loadConnections,
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
const connectPromise = store.connect(config);
await vi.waitFor(() => expect(loadConnections).toHaveBeenCalledOnce());
const replacement = {
...config,
name: "Replacement MongoDB",
host: "replacement.example.com",
};
await store.updateConnection(replacement);
resolveLoadConnections([legacyConfig]);
await connectPromise;
expect(store.getConfig(config.id)).toMatchObject({
name: "Replacement MongoDB",
host: "replacement.example.com",
driver_profile: "mongodb",
driver_label: "MongoDB",
});
});
});
+28 -8
View File
@@ -99,7 +99,8 @@ import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilit
import { useSettingsStore } from "@/stores/settingsStore";
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mongoCollectionKindFromNode, toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
@@ -2451,19 +2452,31 @@ export const useConnectionStore = defineStore("connection", () => {
}
async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) {
if (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
if (previousConfig.db_type !== "mongodb" || isMongoLegacyDriverProfile(previousConfig.driver_profile)) {
return;
}
const expectedConfigFingerprint = connectionConfigFingerprint(previousConfig);
const current = connections.value.find((connection) => connection.id === connectionId);
if (!current || connectionConfigFingerprint(current) !== expectedConfigFingerprint) return;
const savedConnections = await api.loadConnections().catch(() => null);
const savedConfig = savedConnections?.map((connection) => normalizeConnection(connection)).find((connection) => connection.id === connectionId && connection.driver_profile === MONGO_LEGACY_DRIVER_PROFILE);
if (!savedConfig) return;
const savedOriginalIdentity = {
...savedConfig,
driver_profile: previousConfig.driver_profile,
driver_label: previousConfig.driver_label,
};
if (connectionConfigFingerprint(savedOriginalIdentity) !== expectedConfigFingerprint) return;
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
if (idx < 0) return;
if (idx < 0 || connectionConfigFingerprint(connections.value[idx]) !== expectedConfigFingerprint) return;
const nextConnections = [...connections.value];
nextConnections[idx] = {
...savedConfig,
...nextConnections[idx],
driver_profile: MONGO_LEGACY_DRIVER_PROFILE,
driver_label: savedConfig.driver_label || MONGO_LEGACY_DRIVER_LABEL,
};
connections.value = nextConnections;
@@ -4658,7 +4671,8 @@ export const useConnectionStore = defineStore("connection", () => {
children: [],
});
}
if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) {
const isMongoView = node.type === "mongo-collection" && mongoCollectionKindFromNode(node) === "view";
if ((node.type === "table" || node.type === "mongo-collection") && !isMongoView && !parseSqlServerLinkedSchema(schema)) {
if (metadataCapabilities.indexes && !isXugu) {
children.push({
id: `${parentId}:__indexes`,
@@ -4669,6 +4683,9 @@ export const useConnectionStore = defineStore("connection", () => {
schema,
catalog,
tableName: table,
// Keep the Mongo collection kind available to index actions so
// views do not offer unsupported index creation or deletion.
meta: node.type === "mongo-collection" ? node.meta : undefined,
isExpanded: false,
children: [],
});
@@ -4827,8 +4844,10 @@ export const useConnectionStore = defineStore("connection", () => {
const load = beginTreeNodeLoad(node);
try {
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
if (!metadataCapabilities.indexes) {
const effectiveDbType = effectiveDatabaseTypeForConnection(getConfig(connectionId));
const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType);
const isMongoView = effectiveDbType === "mongodb" && node.type === "group-indexes" && mongoCollectionKindFromNode(node) === "view";
if (!metadataCapabilities.indexes || isMongoView) {
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(targetNode, []);
@@ -4839,6 +4858,7 @@ export const useConnectionStore = defineStore("connection", () => {
const indexes = await api.listIndexes(connectionId, database, querySchema, table, catalog);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const mongoCollectionKind = effectiveDbType === "mongodb" && targetNode.type === "group-indexes" ? mongoCollectionKindFromNode(targetNode) : undefined;
setChildren(
targetNode,
indexes.map((idx) => ({
@@ -4849,7 +4869,7 @@ export const useConnectionStore = defineStore("connection", () => {
database,
schema,
tableName: table,
meta: idx,
meta: mongoCollectionKind ? { ...idx, collectionKind: mongoCollectionKind } : idx,
})),
);
targetNode.isExpanded = true;
+22 -2
View File
@@ -29,6 +29,7 @@ import {
splitMongoCommandRanges,
type MongoAggregateSafetyOptions,
} from "@/lib/mongo/mongoShellCommand";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
@@ -684,6 +685,21 @@ export const useQueryStore = defineStore("query", () => {
appendDebugLog(level, `[DBX][executeTabSql:${event}]`, details);
}
async function refreshLoadedMongoIndexesAfterMutation(connectionId: string, database: string, collection: string, traceId: string) {
const connStore = useConnectionStore();
try {
await refreshLoadedMongoIndexes(connStore, { connectionId, database, collection });
} catch (error) {
queryExecutionLog("warn", "mongo-indexes:refresh-failed", {
traceId,
connectionId,
database,
collection,
error: formatError(error),
});
}
}
async function closeResultSession(tab: QueryTab | undefined, preserveSessionId?: string, throwOnError = false) {
const sessionId = tab?.resultSessionId ?? tab?.result?.session_id;
if (!tab || !sessionId || sessionId === preserveSessionId) return;
@@ -3706,8 +3722,12 @@ export const useQueryStore = defineStore("query", () => {
const result = await api.mongoCreateIndex(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.keys, mongoCommand.options);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCreateIndexToQueryResult(result.name, performance.now() - commandStartedAt))));
} else if (mongoCommand.kind === "dropIndex" || mongoCommand.kind === "dropIndexes") {
const result = await api.mongoDropIndexes(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex");
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt))));
try {
const result = await api.mongoDropIndexes(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex");
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt, result.failures))));
} finally {
await refreshLoadedMongoIndexesAfterMutation(tab.connectionId, currentDatabase, mongoCommand.collection, traceId);
}
} else if (mongoCommand.kind === "dropCollection") {
await api.mongoDropCollection(tab.connectionId, currentDatabase, mongoCommand.collection);
allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(1, performance.now() - commandStartedAt))));
@@ -2,7 +2,7 @@
"protocolVersion": 1,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth"],
"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"],
"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_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_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"]
}
@@ -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", "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", "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_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_collection", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "drop_indexes", "drop_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"
+40 -3
View File
@@ -971,6 +971,7 @@ pub enum AgentCapability {
EtcdWatch,
EtcdLease,
EtcdAuth,
MongoDropDatabase,
MultiSession,
StructuredErrorV1,
}
@@ -1053,7 +1054,7 @@ fn parse_agent_rpc_error_header(header: &str) -> (Option<i64>, String) {
}
impl AgentCapability {
pub const ALL: [Self; 20] = [
pub const ALL: [Self; 21] = [
Self::Connect,
Self::TestConnection,
Self::Metadata,
@@ -1072,6 +1073,7 @@ impl AgentCapability {
Self::EtcdWatch,
Self::EtcdLease,
Self::EtcdAuth,
Self::MongoDropDatabase,
Self::MultiSession,
Self::StructuredErrorV1,
];
@@ -1096,6 +1098,7 @@ impl AgentCapability {
Self::EtcdWatch => "etcd_watch",
Self::EtcdLease => "etcd_lease",
Self::EtcdAuth => "etcd_auth",
Self::MongoDropDatabase => "mongo_drop_database",
Self::MultiSession => "multi_session",
Self::StructuredErrorV1 => "structured_error_v1",
}
@@ -1275,6 +1278,7 @@ pub enum MongoAgentMethod {
CreateIndex,
DropIndexes,
DropCollection,
DropDatabase,
InsertDocument,
UpdateDocument,
UpdateDocuments,
@@ -1283,7 +1287,7 @@ pub enum MongoAgentMethod {
}
impl MongoAgentMethod {
pub const ALL: [Self; 14] = [
pub const ALL: [Self; 15] = [
Self::ListDatabases,
Self::ListCollections,
Self::FindDocuments,
@@ -1293,6 +1297,7 @@ impl MongoAgentMethod {
Self::CreateIndex,
Self::DropIndexes,
Self::DropCollection,
Self::DropDatabase,
Self::InsertDocument,
Self::UpdateDocument,
Self::UpdateDocuments,
@@ -1311,6 +1316,7 @@ impl MongoAgentMethod {
Self::CreateIndex => "create_index",
Self::DropIndexes => "drop_indexes",
Self::DropCollection => "drop_collection",
Self::DropDatabase => "drop_database",
Self::InsertDocument => "insert_document",
Self::UpdateDocument => "update_document",
Self::UpdateDocuments => "update_documents",
@@ -2438,6 +2444,19 @@ impl AgentDriverClient {
self.call_mongo_method(MongoAgentMethod::ListCollections, mongo_database_params(database)).await
}
/// Request collection metadata while accepting old Agents that still
/// return the historical string array and ignore `include_types`.
pub async fn mongo_list_collection_specs<T: DeserializeOwned + Send + 'static>(
&mut self,
database: &str,
) -> Result<T, String> {
self.call_mongo_method(
MongoAgentMethod::ListCollections,
serde_json::json!({ "database": database, "include_types": true }),
)
.await
}
pub async fn mongo_find_documents<T: DeserializeOwned + Send + 'static>(
&mut self,
params: Value,
@@ -2488,6 +2507,13 @@ impl AgentDriverClient {
self.call_mongo_method(MongoAgentMethod::DropCollection, params).await
}
pub async fn mongo_drop_database<T: DeserializeOwned + Send + 'static>(
&mut self,
params: Value,
) -> Result<T, String> {
self.call_mongo_method(MongoAgentMethod::DropDatabase, params).await
}
pub async fn mongo_insert_document<T: DeserializeOwned + Send + 'static>(
&mut self,
params: Value,
@@ -2660,6 +2686,7 @@ pub fn agent_supports_capability(handshake: Option<&AgentHandshake>, capability:
| AgentCapability::EtcdWatch
| AgentCapability::EtcdLease
| AgentCapability::EtcdAuth
| AgentCapability::MongoDropDatabase
) {
return handshake.map(|value| value.supports(capability)).unwrap_or(false);
}
@@ -4242,9 +4269,10 @@ for line in sys.stdin:
assert_eq!(AgentCapability::EtcdWatch.as_str(), "etcd_watch");
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::MultiSession.as_str(), "multi_session");
assert_eq!(AgentCapability::ALL.len(), 20);
assert_eq!(AgentCapability::StructuredErrorV1.as_str(), "structured_error_v1");
assert_eq!(AgentCapability::ALL.len(), 21);
}
#[test]
@@ -4292,6 +4320,7 @@ for line in sys.stdin:
assert_eq!(MongoAgentMethod::CreateIndex.as_str(), "create_index");
assert_eq!(MongoAgentMethod::DropIndexes.as_str(), "drop_indexes");
assert_eq!(MongoAgentMethod::DropCollection.as_str(), "drop_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");
assert_eq!(MongoAgentMethod::UpdateDocuments.as_str(), "update_documents");
@@ -4350,6 +4379,7 @@ for line in sys.stdin:
fn exposes_mongo_protocol_wrappers() {
let _mongo_list_databases = AgentDriverClient::mongo_list_databases::<serde_json::Value>;
let _mongo_list_collections = AgentDriverClient::mongo_list_collections::<serde_json::Value>;
let _mongo_list_collection_specs = AgentDriverClient::mongo_list_collection_specs::<serde_json::Value>;
let _mongo_find_documents = AgentDriverClient::mongo_find_documents::<serde_json::Value>;
let _mongo_find_documents_extended_json =
AgentDriverClient::mongo_find_documents_extended_json::<serde_json::Value>;
@@ -4357,6 +4387,7 @@ for line in sys.stdin:
let _mongo_create_index = AgentDriverClient::mongo_create_index::<serde_json::Value>;
let _mongo_drop_indexes = AgentDriverClient::mongo_drop_indexes::<serde_json::Value>;
let _mongo_drop_collection = AgentDriverClient::mongo_drop_collection::<serde_json::Value>;
let _mongo_drop_database = AgentDriverClient::mongo_drop_database::<serde_json::Value>;
let _mongo_insert_document = AgentDriverClient::mongo_insert_document::<serde_json::Value>;
let _mongo_update_document = AgentDriverClient::mongo_update_document::<serde_json::Value>;
let _mongo_update_documents = AgentDriverClient::mongo_update_documents::<serde_json::Value>;
@@ -4587,6 +4618,12 @@ for line in sys.stdin:
assert!(!agent_supports_capability(Some(&handshake), AgentCapability::KvStatus));
assert!(!agent_supports_capability(None, AgentCapability::KvHistory));
assert!(!agent_supports_capability(Some(&handshake), AgentCapability::KvHistory));
assert!(!agent_supports_capability(None, AgentCapability::MongoDropDatabase));
assert!(!agent_supports_capability(Some(&handshake), AgentCapability::MongoDropDatabase));
let mongo_handshake =
AgentHandshake { capabilities: vec![AgentCapability::MongoDropDatabase.as_str().to_string()], ..handshake };
assert!(agent_supports_capability(Some(&mongo_handshake), AgentCapability::MongoDropDatabase));
}
#[test]
+335 -46
View File
@@ -20,6 +20,14 @@ pub type MongoDocumentResult = DocumentQueryResult;
pub struct MongoDropIndexesResult {
pub dropped_names: Vec<String>,
pub affected_rows: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failures: Vec<MongoDropIndexFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MongoDropIndexFailure {
pub name: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -222,6 +230,16 @@ impl MongoCollectionKind {
_ => Self::Collection,
}
}
/// Convert the Legacy Agent's optional listCollections metadata. Unknown
/// values stay compatible with the former name-only response.
pub fn from_metadata_kind(kind: Option<&str>) -> Self {
match kind.map(str::trim) {
Some(kind) if kind.eq_ignore_ascii_case("view") => Self::View,
Some(kind) if kind.eq_ignore_ascii_case("timeseries") => Self::Timeseries,
_ => Self::Collection,
}
}
}
/// Name + kind for a MongoDB collection/view/timeseries returned by full `listCollections`.
@@ -528,25 +546,26 @@ pub async fn create_database(client: &Client, database: &str) -> Result<(), Stri
}
pub async fn drop_database(client: &Client, database: &str) -> Result<(), String> {
let database = database.trim();
if database.is_empty() {
return Err("Database name is required".to_string());
}
let database = validate_mongo_namespace_name(database, "Database")?;
client.database(database).drop().await.map_err(|e| e.to_string())
}
pub async fn drop_collection(client: &Client, database: &str, collection: &str) -> Result<(), String> {
let database = database.trim();
let collection = collection.trim();
if database.is_empty() {
return Err("Database name is required".to_string());
}
if collection.is_empty() {
return Err("Collection name is required".to_string());
}
let database = validate_mongo_namespace_name(database, "Database")?;
let collection = validate_mongo_namespace_name(collection, "Collection")?;
client.database(database).collection::<Document>(collection).drop().await.map_err(|e| e.to_string())
}
/// MongoDB namespace identifiers must reach the server unchanged. In
/// particular, a collection name may legitimately contain leading or trailing
/// whitespace, so validation only rejects the empty identifier.
pub fn validate_mongo_namespace_name<'a>(name: &'a str, kind: &str) -> Result<&'a str, String> {
if name.is_empty() {
return Err(format!("{kind} name is required"));
}
Ok(name)
}
/// Build the admin `renameCollection` command document for a same-database rename.
///
/// Collection (and database) names are identifiers and must be passed exactly as provided —
@@ -1020,9 +1039,44 @@ pub async fn create_index(
keys_json: &str,
options_json: Option<&str>,
) -> Result<String, String> {
let database = validate_mongo_namespace_name(database, "Database")?;
let collection = validate_mongo_namespace_name(collection, "Collection")?;
let (command, name) = create_indexes_command(collection, keys_json, options_json)?;
client.database(database).run_command(command).await.map_err(|e| e.kind.to_string())?;
Ok(name)
}
/// Validate an index request before it reaches either the native driver or the
/// Legacy Agent. In particular, `key` belongs to the createIndexes command
/// itself and must not be smuggled through the options document.
pub fn validate_create_index_request(keys_json: &str, options_json: Option<&str>) -> Result<(), String> {
parse_create_index_spec(keys_json, options_json).map(|_| ())
}
fn create_indexes_command(
collection: &str,
keys_json: &str,
options_json: Option<&str>,
) -> Result<(Document, String), String> {
let (keys, options, name) = parse_create_index_spec(keys_json, options_json)?;
let mut index = doc! { "key": keys };
for (option, value) in options {
index.insert(option, value);
}
Ok((doc! { "createIndexes": collection, "indexes": [index] }, name))
}
fn parse_create_index_spec(
keys_json: &str,
options_json: Option<&str>,
) -> Result<(Document, Document, String), String> {
let keys_value: serde_json::Value =
serde_json::from_str(keys_json).map_err(|e| format!("Invalid index keys JSON: {e}"))?;
let keys = json_object_to_document(&keys_value).map_err(|e| format!("Invalid index keys: {e}"))?;
// The internal transport uses JSON rather than a Mongo shell expression.
// Keep ordinary strings literal while supporting official Extended JSON
// wrappers, matching the Legacy Agent's Document.parse behavior.
let keys = json_object_to_document_extended_json(&keys_value).map_err(|e| format!("Invalid index keys: {e}"))?;
if keys.is_empty() {
return Err("Index keys are required".to_string());
}
@@ -1031,16 +1085,40 @@ pub async fn create_index(
Some(json) => {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("Invalid index options JSON: {e}"))?;
let doc = json_object_to_document(&value).map_err(|e| format!("Invalid index options: {e}"))?;
Some(mongodb::bson::from_document::<IndexOptions>(doc).map_err(|e| format!("Invalid index options: {e}"))?)
json_object_to_document_extended_json(&value).map_err(|e| format!("Invalid index options: {e}"))?
}
None => None,
None => Document::new(),
};
if options.contains_key("key") {
return Err("Index options cannot contain \"key\"; specify index fields in keys JSON".to_string());
}
let name = match options.get("name") {
Some(Bson::String(name)) if !name.trim().is_empty() => name.clone(),
Some(_) => return Err("Index option \"name\" must be a non-empty string".to_string()),
None => default_index_name(&keys),
};
let col = client.database(database).collection::<Document>(collection);
let result =
col.create_index(IndexModel::builder().keys(keys).options(options).build()).await.map_err(|e| e.to_string())?;
Ok(result.index_name)
// Use the raw server command instead of deserializing into the driver's
// fixed IndexOptions struct. This keeps native and Legacy Agent requests
// equivalent and lets the connected MongoDB version validate new options.
let mut options = options;
if !options.contains_key("name") {
options.insert("name", name.clone());
}
Ok((keys, options, name))
}
fn default_index_name(keys: &Document) -> String {
keys.iter()
.map(|(field, value)| {
let value = match value {
Bson::String(value) => value.clone(),
value => value.to_string(),
};
format!("{field}_{value}")
})
.collect::<Vec<_>>()
.join("_")
}
pub async fn drop_indexes(
@@ -1050,14 +1128,8 @@ pub async fn drop_indexes(
indexes_json: Option<&str>,
single: bool,
) -> Result<MongoDropIndexesResult, String> {
let database = database.trim();
let collection = collection.trim();
if database.is_empty() {
return Err("Database name is required".to_string());
}
if collection.is_empty() {
return Err("Collection name is required".to_string());
}
let database = validate_mongo_namespace_name(database, "Database")?;
let collection = validate_mongo_namespace_name(collection, "Collection")?;
let index = parse_drop_indexes_value(indexes_json, single)?;
let before = list_indexes(client, database, collection).await?;
@@ -1068,7 +1140,57 @@ pub async fn drop_indexes(
.map_err(|e| e.to_string())?;
let after = list_indexes(client, database, collection).await?;
let dropped_names = diff_dropped_index_names(&before, &after);
Ok(MongoDropIndexesResult { affected_rows: dropped_names.len() as u64, dropped_names })
Ok(MongoDropIndexesResult { affected_rows: dropped_names.len() as u64, dropped_names, failures: Vec::new() })
}
/// Validate a drop-index request before it is sent to either MongoDB driver.
/// The Legacy Agent receives the original JSON, so keeping this validation
/// public gives both paths the same protection for MongoDB's default index.
pub fn validate_drop_indexes_request(indexes_json: Option<&str>, single: bool) -> Result<(), String> {
parse_drop_indexes_value(indexes_json, single).map(|_| ())
}
/// MongoDB added array-form `dropIndexes.index` in 4.2. Unknown version
/// strings preserve the modern single-command semantics instead of risking a
/// partially applied serial fallback.
pub fn mongo_server_requires_serial_drop_indexes(version: &str) -> bool {
let Some(start) = version.find(|character: char| character.is_ascii_digit()) else {
return false;
};
let mut components = version[start..].split('.');
let Some(major) = components.next().and_then(parse_version_component) else {
return false;
};
let Some(minor) = components.next().and_then(parse_version_component) else {
return false;
};
(major, minor) < (4, 2)
}
fn parse_version_component(component: &str) -> Option<u32> {
let digits = component.chars().take_while(|character| character.is_ascii_digit()).collect::<String>();
(!digits.is_empty()).then(|| digits.parse().ok()).flatten()
}
/// Return names that must be issued as individual dropIndex commands for
/// MongoDB 3.4 compatibility. The original request is fully parsed first, so
/// an invalid name (including `_id_`) cannot cause a partial batch mutation.
pub fn serial_drop_index_names(indexes_json: Option<&str>, single: bool) -> Result<Option<Vec<String>>, String> {
let Bson::Array(indexes) = parse_drop_indexes_value(indexes_json, single)? else {
return Ok(None);
};
let mut seen = HashSet::new();
let mut names = Vec::new();
for index in indexes {
let Bson::String(name) = index else {
return Err("dropIndexes only accepts arrays of string index names".to_string());
};
if seen.insert(name.clone()) {
names.push(name);
}
}
Ok(Some(names))
}
fn diff_dropped_index_names(before: &[IndexInfo], after: &[IndexInfo]) -> Vec<String> {
@@ -1086,51 +1208,64 @@ fn parse_drop_indexes_value(indexes_json: Option<&str>, single: bool) -> Result<
fn parse_drop_indexes_json(json: &str, single: bool) -> Result<Bson, String> {
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("Invalid index JSON: {e}"))?;
let bson = json_value_to_bson(&value);
if single {
validate_single_drop_index_value(&value)?;
validate_single_drop_index_value(&bson)?;
} else {
validate_multi_drop_indexes_value(&value)?;
validate_multi_drop_indexes_value(&bson)?;
}
Ok(json_value_to_bson(&value))
Ok(bson)
}
fn validate_single_drop_index_value(value: &serde_json::Value) -> Result<(), String> {
fn validate_single_drop_index_value(value: &Bson) -> Result<(), String> {
match value {
serde_json::Value::String(name) => {
Bson::String(name) => {
if name.trim().is_empty() {
Err("Index name is required".to_string())
} else if name == "*" {
Err(r#"dropIndex does not accept "*"; use dropIndexes() or dropIndexes("*") instead"#.to_string())
} else if name == "_id_" {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
} else {
Ok(())
}
}
serde_json::Value::Object(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
serde_json::Value::Object(_) => Ok(()),
serde_json::Value::Array(_) => {
Bson::Document(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
Bson::Document(doc) if is_default_id_index_specification(doc) => {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
}
Bson::Document(_) => Ok(()),
Bson::Array(_) => {
Err("dropIndex only accepts a string index name or JSON document; arrays are not supported".to_string())
}
_ => Err("dropIndex only accepts a string index name or JSON document".to_string()),
}
}
fn validate_multi_drop_indexes_value(value: &serde_json::Value) -> Result<(), String> {
fn validate_multi_drop_indexes_value(value: &Bson) -> Result<(), String> {
match value {
serde_json::Value::String(name) => {
Bson::String(name) => {
if name.trim().is_empty() {
Err("Index name is required".to_string())
} else if name == "_id_" {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
} else {
Ok(())
}
}
serde_json::Value::Object(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
serde_json::Value::Object(_) => Ok(()),
serde_json::Value::Array(items) if items.is_empty() => {
Err("dropIndexes only accepts non-empty string arrays".to_string())
Bson::Document(doc) if doc.is_empty() => Err("Index specification is required".to_string()),
Bson::Document(doc) if is_default_id_index_specification(doc) => {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
}
serde_json::Value::Array(items) => {
if items.iter().all(|item| matches!(item, serde_json::Value::String(name) if !name.trim().is_empty())) {
Ok(())
Bson::Document(_) => Ok(()),
Bson::Array(items) if items.is_empty() => Err("dropIndexes only accepts non-empty string arrays".to_string()),
Bson::Array(items) => {
if items.iter().all(|item| matches!(item, Bson::String(name) if !name.trim().is_empty())) {
if items.iter().any(|item| matches!(item, Bson::String(name) if name == "_id_")) {
Err("The default MongoDB _id_ index cannot be dropped".to_string())
} else {
Ok(())
}
} else {
Err("dropIndexes only accepts arrays of string index names".to_string())
}
@@ -1139,6 +1274,20 @@ fn validate_multi_drop_indexes_value(value: &serde_json::Value) -> Result<(), St
}
}
fn is_default_id_index_specification(specification: &Document) -> bool {
specification.len() == 1 && specification.get("_id").is_some_and(is_bson_numeric_one)
}
fn is_bson_numeric_one(value: &Bson) -> bool {
match value {
Bson::Int32(value) => *value == 1,
Bson::Int64(value) => *value == 1,
Bson::Double(value) => *value == 1.0,
Bson::Decimal128(value) => value.to_string().parse::<f64>().is_ok_and(|value| value == 1.0),
_ => false,
}
}
pub async fn insert_document(
client: &Client,
database: &str,
@@ -2592,6 +2741,102 @@ mod tests {
assert!(index.is_primary);
}
#[test]
fn mongo_namespace_validation_preserves_whitespace_and_rejects_only_empty_names() {
assert_eq!(validate_mongo_namespace_name(" app ", "Database").unwrap(), " app ");
assert_eq!(validate_mongo_namespace_name(" users ", "Collection").unwrap(), " users ");
assert_eq!(validate_mongo_namespace_name(" ", "Collection").unwrap(), " ");
let error = validate_mongo_namespace_name("", "Collection").unwrap_err();
assert_eq!(error, "Collection name is required");
}
#[test]
fn create_indexes_command_keeps_raw_options_and_generates_a_name() {
let (command, name) = create_indexes_command(
"users",
r#"{"email":1,"createdAt":-1,"content":"text"}"#,
Some(
r#"{"unique":true,"partialFilterExpression":{"verified":true},"customServerOption":{"enabled":true}}"#,
),
)
.unwrap();
assert_eq!(name, "email_1_createdAt_-1_content_text");
assert_eq!(command.get_str("createIndexes").unwrap(), "users");
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
assert_eq!(
index.get_document("key").unwrap().keys().collect::<Vec<_>>(),
vec!["email", "createdAt", "content"],
"compound-index key order is part of MongoDB index semantics"
);
assert_eq!(
index.get_document("key").unwrap(),
&doc! { "email": 1_i32, "createdAt": -1_i32, "content": "text" }
);
assert!(index.get_bool("unique").unwrap());
assert_eq!(index.get_str("name").unwrap(), "email_1_createdAt_-1_content_text");
assert_eq!(index.get_document("customServerOption").unwrap(), &doc! { "enabled": true });
}
#[test]
fn create_indexes_command_canonicalizes_a_whole_double_in_the_default_name() {
let (command, name) = create_indexes_command("users", r#"{"email":1.0}"#, None).unwrap();
assert_eq!(name, "email_1");
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
assert_eq!(index.get_str("name").unwrap(), "email_1");
assert!(
matches!(index.get("key"), Some(Bson::Document(keys)) if matches!(keys.get("email"), Some(Bson::Double(value)) if *value == 1.0))
);
}
#[test]
fn create_indexes_command_preserves_extended_json_options() {
let (command, name) = create_indexes_command(
"events",
r#"{"expiresAt":1}"#,
Some(r#"{"name":"expires_ttl","partialFilterExpression":{"createdAt":{"$date":"2026-06-10T13:59:31.287Z"}}}"#),
)
.unwrap();
assert_eq!(name, "expires_ttl");
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
let filter = index.get_document("partialFilterExpression").unwrap();
assert!(matches!(filter.get("createdAt"), Some(Bson::DateTime(_))));
}
#[test]
fn create_indexes_command_keeps_plain_json_strings_literal() {
let (command, _) = create_indexes_command(
"events",
r#"{"expiresAt":1}"#,
Some(r#"{"partialFilterExpression":{"source":"ISODate(\"2026-06-10T13:59:31.287Z\")"}}"#),
)
.unwrap();
let index = command.get_array("indexes").unwrap()[0].as_document().unwrap();
let filter = index.get_document("partialFilterExpression").unwrap();
assert!(
matches!(filter.get("source"), Some(Bson::String(value)) if value == "ISODate(\"2026-06-10T13:59:31.287Z\")")
);
}
#[test]
fn create_indexes_command_rejects_key_inside_options() {
let error = create_indexes_command("users", r#"{"email":1}"#, Some(r#"{"key":{"other":1}}"#)).unwrap_err();
assert!(error.contains("cannot contain \"key\""), "{error}");
}
#[test]
fn create_indexes_command_rejects_empty_or_non_string_names() {
for options in [r#"{"name":""}"#, r#"{"name":" "}"#, r#"{"name":null}"#, r#"{"name":1}"#] {
let error = create_indexes_command("users", r#"{"email":1}"#, Some(options)).unwrap_err();
assert!(error.contains("non-empty string"), "{error}");
}
}
#[test]
fn parse_drop_indexes_value_validates_drop_index_arguments() {
assert!(matches!(
@@ -2642,6 +2887,50 @@ mod tests {
assert!(invalid_array.contains("arrays of string index names"));
}
#[test]
fn drop_indexes_validation_rejects_the_default_id_index() {
for (indexes_json, single) in [
(Some(r#""_id_""#), true),
(Some(r#""_id_""#), false),
(Some(r#"{"_id":1}"#), true),
(Some(r#"{"_id":1}"#), false),
(Some(r#"{"_id":1.0}"#), true),
(Some(r#"{"_id":{"$numberInt":"1"}}"#), true),
(Some(r#"{"_id":{"$numberLong":"1"}}"#), false),
(Some(r#"{"_id":{"$numberDouble":"1.0"}}"#), false),
(Some(r#"{"_id":{"$numberDecimal":"1.0"}}"#), true),
(Some(r#"["email_1","_id_"]"#), false),
] {
let error = validate_drop_indexes_request(indexes_json, single).unwrap_err();
assert!(error.contains("_id_"), "{error}");
}
// MongoDB defines dropIndexes("*") to retain the default _id_ index.
assert!(matches!(validate_drop_indexes_request(Some(r#""*""#), false), Ok(())));
}
#[test]
fn serial_drop_index_names_is_portable_and_prevalidates_all_names() {
assert_eq!(
serial_drop_index_names(Some(r#"["email_1","email_1","createdAt_-1"]"#), false).unwrap(),
Some(vec!["email_1".to_string(), "createdAt_-1".to_string()])
);
assert_eq!(serial_drop_index_names(Some(r#""*""#), false).unwrap(), None);
let error = serial_drop_index_names(Some(r#"["email_1","_id_"]"#), false).unwrap_err();
assert!(error.contains("_id_"), "{error}");
}
#[test]
fn serial_drop_indexes_fallback_is_limited_to_pre_42_servers() {
for version in ["3.4.24", "4.0.28", "MongoDB 3.6.23"] {
assert!(mongo_server_requires_serial_drop_indexes(version), "{version}");
}
for version in ["4.2.0", "4.4.29", "5.0.0-rc0", "8.0.1", "unknown", "4"] {
assert!(!mongo_server_requires_serial_drop_indexes(version), "{version}");
}
}
#[test]
fn diff_dropped_index_names_reports_removed_indexes() {
let before = vec![
+61 -9
View File
@@ -98,6 +98,37 @@ fn sort_mongo_collection_specs(
specs
}
/// Decode both generations of the Legacy Agent response. Existing installed
/// Agents return names, while current Agents opt into `name` + `kind` specs.
fn mongo_collection_specs_from_agent_response(
value: serde_json::Value,
) -> Result<Vec<mongo_driver::MongoCollectionSpec>, String> {
let values =
value.as_array().ok_or_else(|| "Invalid MongoDB legacy collection list: expected an array".to_string())?;
values
.iter()
.map(|value| match value {
serde_json::Value::String(name) => Ok(mongo_driver::MongoCollectionSpec {
name: name.clone(),
kind: mongo_driver::MongoCollectionKind::Collection,
}),
serde_json::Value::Object(spec) => {
let name = spec
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "Invalid MongoDB legacy collection spec: name is required".to_string())?;
let kind = spec.get("kind").or_else(|| spec.get("type")).and_then(serde_json::Value::as_str);
Ok(mongo_driver::MongoCollectionSpec {
name: name.to_string(),
kind: mongo_driver::MongoCollectionKind::from_metadata_kind(kind),
})
}
_ => Err("Invalid MongoDB legacy collection list entry".to_string()),
})
.collect()
}
pub(crate) fn mongo_gridfs_bucket_names(names: &[String]) -> Vec<String> {
use std::collections::BTreeSet;
@@ -225,14 +256,12 @@ pub async fn list_collections_core(
PoolKind::VectorDb(client) => vector_driver::list_collections_with_db(client, database).await,
PoolKind::Agent(client) => {
let mut client = client.lock().await;
let names = sort_names(client.mongo_list_collections(database).await?);
let specs = sort_mongo_collection_specs(mongo_collection_specs_from_agent_response(
client.mongo_list_collection_specs(database).await?,
)?);
let names: Vec<String> = specs.iter().map(|spec| spec.name.clone()).collect();
let mut infos = mongo_bucket_infos(&names);
// Legacy agent returns names only; treat every entry as a plain collection.
infos.extend(
names
.into_iter()
.map(|name| mongo_collection_info(name, mongo_driver::MongoCollectionKind::Collection)),
);
infos.extend(specs.into_iter().map(|spec| mongo_collection_info(spec.name, spec.kind)));
Ok(infos)
}
_ => Err("Not a MongoDB/Elasticsearch/vector connection".to_string()),
@@ -581,9 +610,11 @@ pub async fn delete_document_core_with_type(
#[cfg(test)]
mod tests {
use super::{
fallback_mongo_database, filter_and_sort_gridfs_bucket_infos, mongo_gridfs_bucket_names,
mongo_list_databases_unauthorized, parse_gridfs_bucket_sort, sort_names, MongoGridFsBucketInfo,
fallback_mongo_database, filter_and_sort_gridfs_bucket_infos, mongo_collection_specs_from_agent_response,
mongo_gridfs_bucket_names, mongo_list_databases_unauthorized, parse_gridfs_bucket_sort, sort_names,
MongoGridFsBucketInfo,
};
use crate::db::mongo_driver::MongoCollectionKind;
#[test]
fn sorts_names_case_insensitively() {
@@ -628,6 +659,27 @@ mod tests {
assert_eq!(buckets, vec!["orders".to_string(), "reports".to_string()]);
}
#[test]
fn decodes_legacy_collection_names_and_type_aware_specs() {
let specs = mongo_collection_specs_from_agent_response(serde_json::json!([
"orders",
{ "name": "report_view", "kind": "view" },
{ "name": "metrics", "kind": "timeseries" },
{ "name": "future_kind", "kind": "unknown" }
]))
.unwrap();
assert_eq!(
specs.into_iter().map(|spec| (spec.name, spec.kind)).collect::<Vec<_>>(),
vec![
("orders".to_string(), MongoCollectionKind::Collection),
("report_view".to_string(), MongoCollectionKind::View),
("metrics".to_string(), MongoCollectionKind::Timeseries),
("future_kind".to_string(), MongoCollectionKind::Collection),
]
);
}
#[test]
fn filters_gridfs_buckets_by_case_insensitive_name_match() {
let buckets = filter_and_sort_gridfs_bucket_infos(
+464 -3
View File
@@ -1,5 +1,8 @@
use crate::connection::{AppState, PoolKind};
use crate::db::mongo_driver::{self, MongoCollectionStatsResult, MongoDocumentResult, MongoDropIndexesResult};
use crate::db::agent_driver::AgentCapability;
use crate::db::mongo_driver::{
self, MongoCollectionStatsResult, MongoDocumentResult, MongoDropIndexFailure, MongoDropIndexesResult,
};
use crate::document_ops::CollectionInfo;
async fn ensure_document_pool(state: &AppState, connection_id: &str) -> Result<(), String> {
@@ -29,11 +32,22 @@ pub async fn mongo_create_database_core(state: &AppState, connection_id: &str, d
}
pub async fn mongo_drop_database_core(state: &AppState, connection_id: &str, database: &str) -> Result<(), String> {
mongo_driver::validate_mongo_namespace_name(database, "Database")?;
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::drop_database(client, database).await,
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support drop database".to_string()),
PoolKind::Agent(client) => {
let mut client = client.lock().await;
if !client.supports_capability(AgentCapability::MongoDropDatabase) {
return Err(
"MongoDB Legacy Agent does not support drop database; upgrade or reinstall the MongoDB Legacy driver"
.to_string(),
);
}
let _: serde_json::Value = client.mongo_drop_database(serde_json::json!({ "database": database })).await?;
Ok(())
}
_ => Err("Not a MongoDB connection".to_string()),
}
}
@@ -44,6 +58,8 @@ pub async fn mongo_drop_collection_core(
database: &str,
collection: &str,
) -> Result<(), String> {
mongo_driver::validate_mongo_namespace_name(database, "Database")?;
mongo_driver::validate_mongo_namespace_name(collection, "Collection")?;
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
@@ -300,6 +316,11 @@ pub async fn mongo_create_index_core(
keys_json: &str,
options_json: Option<&str>,
) -> Result<String, String> {
// The Legacy Agent receives the original JSON, so validate the shared
// createIndexes contract before selecting a driver implementation.
mongo_driver::validate_mongo_namespace_name(database, "Database")?;
mongo_driver::validate_mongo_namespace_name(collection, "Collection")?;
mongo_driver::validate_create_index_request(keys_json, options_json)?;
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
@@ -316,7 +337,12 @@ pub async fn mongo_create_index_core(
"options_json": options_json,
}))
.await?;
Ok(result.get("name").and_then(|value| value.as_str()).unwrap_or("").to_string())
result
.get("name")
.and_then(serde_json::Value::as_str)
.filter(|name| !name.is_empty())
.map(str::to_string)
.ok_or_else(|| "MongoDB legacy agent returned no created index name".to_string())
}
_ => Err("Not a MongoDB connection".to_string()),
}
@@ -330,6 +356,52 @@ pub async fn mongo_drop_indexes_core(
indexes_json: Option<&str>,
single: bool,
) -> Result<MongoDropIndexesResult, String> {
mongo_driver::validate_mongo_namespace_name(database, "Database")?;
mongo_driver::validate_mongo_namespace_name(collection, "Collection")?;
let serial_names = mongo_driver::serial_drop_index_names(indexes_json, single)?;
if let Some(names) = serial_names {
let requires_serial_fallback = match mongo_server_version_core(state, connection_id, database).await {
Ok(version) => mongo_driver::mongo_server_requires_serial_drop_indexes(&version),
Err(error) => {
log::warn!(
"[mongo][drop-indexes] server version unavailable; preserving array command semantics: {error}"
);
false
}
};
if requires_serial_fallback {
let mut dropped_names = Vec::new();
let mut failures = Vec::new();
for name in names {
let index_json = serde_json::to_string(&name).map_err(|error| error.to_string())?;
match mongo_drop_indexes_once_core(state, connection_id, database, collection, Some(&index_json), true)
.await
{
Ok(result) => {
dropped_names.extend(result.dropped_names);
failures.extend(result.failures);
}
Err(message) => failures.push(MongoDropIndexFailure { name, message }),
}
}
return Ok(MongoDropIndexesResult { affected_rows: dropped_names.len() as u64, dropped_names, failures });
}
}
mongo_drop_indexes_once_core(state, connection_id, database, collection, indexes_json, single).await
}
async fn mongo_drop_indexes_once_core(
state: &AppState,
connection_id: &str,
database: &str,
collection: &str,
indexes_json: Option<&str>,
single: bool,
) -> Result<MongoDropIndexesResult, String> {
// Apply the same argument and default-index protection before dispatching
// to Native MongoDB or the Legacy Agent.
mongo_driver::validate_drop_indexes_request(indexes_json, single)?;
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
@@ -550,3 +622,392 @@ pub async fn mongo_find_one_and_delete_core(
_ => Err("Not a MongoDB connection".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
#[cfg(unix)]
use crate::models::connection::ConnectionConfig;
#[cfg(unix)]
use crate::storage::Storage;
#[cfg(unix)]
async fn legacy_mongo_state(
expected_method: &str,
expected_params: serde_json::Value,
expected_result: serde_json::Value,
) -> (AppState, tempfile::TempDir) {
legacy_mongo_state_with_options(
expected_method,
expected_params,
expected_result,
&[AgentCapability::MongoDropDatabase.as_str()],
None,
None,
)
.await
}
#[cfg(unix)]
async fn legacy_mongo_state_with_capabilities(
expected_method: &str,
expected_params: serde_json::Value,
expected_result: serde_json::Value,
capabilities: &[&str],
) -> (AppState, tempfile::TempDir) {
legacy_mongo_state_with_options(expected_method, expected_params, expected_result, capabilities, None, None)
.await
}
#[cfg(unix)]
async fn legacy_mongo_state_with_server_version(
expected_method: &str,
expected_params: serde_json::Value,
expected_result: serde_json::Value,
server_version: &str,
) -> (AppState, tempfile::TempDir) {
legacy_mongo_state_with_options(
expected_method,
expected_params,
expected_result,
&[AgentCapability::MongoDropDatabase.as_str()],
Some(server_version),
None,
)
.await
}
#[cfg(unix)]
async fn legacy_mongo_state_with_server_error(
expected_method: &str,
expected_params: serde_json::Value,
expected_error: &str,
server_version: &str,
) -> (AppState, tempfile::TempDir) {
legacy_mongo_state_with_options(
expected_method,
expected_params,
serde_json::Value::Null,
&[AgentCapability::MongoDropDatabase.as_str()],
Some(server_version),
Some(expected_error),
)
.await
}
#[cfg(unix)]
async fn legacy_mongo_state_with_options(
expected_method: &str,
expected_params: serde_json::Value,
expected_result: serde_json::Value,
capabilities: &[&str],
server_version: Option<&str>,
expected_error: Option<&str>,
) -> (AppState, tempfile::TempDir) {
use std::io::Write;
let directory = tempfile::tempdir().unwrap();
let mut script = tempfile::NamedTempFile::new_in(directory.path()).unwrap();
let expected_method = serde_json::to_string(expected_method).unwrap();
let expected_params = serde_json::to_string(&serde_json::to_string(&expected_params).unwrap()).unwrap();
let expected_result = serde_json::to_string(&serde_json::to_string(&expected_result).unwrap()).unwrap();
let capabilities = serde_json::to_string(capabilities).unwrap();
let python_optional_string = |value: Option<&str>| {
value.map(|value| serde_json::to_string(value).unwrap()).unwrap_or_else(|| "None".to_string())
};
let server_version = python_optional_string(server_version);
let expected_error = python_optional_string(expected_error);
write!(
script,
r#"import json
import sys
EXPECTED_METHOD = {expected_method}
EXPECTED_PARAMS = json.loads({expected_params})
EXPECTED_RESULT = json.loads({expected_result})
CAPABILITIES = {capabilities}
SERVER_VERSION = {server_version}
EXPECTED_ERROR = {expected_error}
print(json.dumps({{"ready": True}}), flush=True)
for line in sys.stdin:
request = json.loads(line)
if request.get("method") == "handshake":
result = {{"protocolVersion": 1, "agentProtocolVersion": 1, "capabilities": CAPABILITIES}}
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "result": result}}), flush=True)
continue
if request.get("method") == "validate_connection":
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "result": {{"ok": True}}}}), flush=True)
continue
if request.get("method") == "server_version" and SERVER_VERSION is not None:
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "result": SERVER_VERSION}}), flush=True)
continue
if request.get("method") != EXPECTED_METHOD or request.get("params") != EXPECTED_PARAMS:
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "error": {{"code": -1, "message": "unexpected MongoDB RPC"}}}}), flush=True)
continue
if EXPECTED_ERROR is not None:
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "error": {{"code": -1, "message": EXPECTED_ERROR}}}}), flush=True)
else:
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "result": EXPECTED_RESULT}}), flush=True)
"#
)
.unwrap();
script.flush().unwrap();
// Keep the script available until the spawned interpreter has opened it.
let (_, script_path) = script.keep().unwrap();
let mut client = AgentDriverClient::spawn(
AgentLaunchSpec::new("python3").with_args([script_path.to_string_lossy().to_string()]),
)
.await
.unwrap();
client.try_optional_handshake("test").await.unwrap();
let storage = Storage::open(&directory.path().join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "legacy",
"name": "Legacy MongoDB",
"db_type": "mongodb",
"driver_profile": "mongodb-legacy",
"host": "localhost",
"port": 27017,
"username": "",
"password": "",
"database": null,
}))
.unwrap();
state.configs.write().await.insert("legacy".to_string(), config);
state.connections.write().await.insert("legacy".to_string(), PoolKind::agent(client));
(state, directory)
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_drop_database_routes_legacy_connections_to_the_agent() {
let (state, _directory) = legacy_mongo_state(
"drop_database",
serde_json::json!({ "database": "app" }),
serde_json::json!({ "ok": true }),
)
.await;
mongo_drop_database_core(&state, "legacy", "app").await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_drop_database_requires_an_explicit_legacy_agent_capability() {
let (state, _directory) = legacy_mongo_state_with_capabilities(
"drop_database",
serde_json::json!({ "database": "app" }),
serde_json::json!({ "ok": true }),
&[],
)
.await;
let error = mongo_drop_database_core(&state, "legacy", "app").await.unwrap_err();
assert!(error.contains("upgrade or reinstall"), "{error}");
assert!(!error.contains("Unknown method"), "{error}");
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_drop_collection_routes_legacy_connections_to_the_agent() {
let (state, _directory) = legacy_mongo_state(
"drop_collection",
serde_json::json!({
"database": "app",
"collection": "users",
}),
serde_json::json!({ "ok": true }),
)
.await;
mongo_drop_collection_core(&state, "legacy", "app", "users").await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_list_collections_requests_legacy_collection_types() {
let (state, _directory) = legacy_mongo_state(
"list_collections",
serde_json::json!({ "database": "app", "include_types": true }),
serde_json::json!([
{ "name": "report_view", "kind": "view" },
{ "name": "metrics", "kind": "timeseries" },
"orders"
]),
)
.await;
let collections = mongo_list_collections_core(&state, "legacy", "app").await.unwrap();
assert_eq!(
collections.into_iter().map(|collection| (collection.name, collection.kind)).collect::<Vec<_>>(),
vec![
("metrics".to_string(), Some("timeseries".to_string())),
("orders".to_string(), Some("collection".to_string())),
("report_view".to_string(), Some("view".to_string())),
]
);
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_create_index_routes_legacy_connections_to_the_agent() {
let keys_json = r#"{"email":1}"#;
let options_json = r#"{"name":"email_1","unique":true}"#;
let (state, _directory) = legacy_mongo_state(
"create_index",
serde_json::json!({
"database": "app",
"collection": "users",
"keys_json": keys_json,
"options_json": options_json,
}),
serde_json::json!({ "name": "email_1" }),
)
.await;
let name =
mongo_create_index_core(&state, "legacy", "app", "users", keys_json, Some(options_json)).await.unwrap();
assert_eq!(name, "email_1");
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_create_index_requires_a_name_from_the_legacy_agent() {
let (state, _directory) = legacy_mongo_state(
"create_index",
serde_json::json!({
"database": "app",
"collection": "users",
"keys_json": "{\"email\":1}",
"options_json": null,
}),
serde_json::json!({}),
)
.await;
let error =
mongo_create_index_core(&state, "legacy", "app", "users", r#"{"email":1}"#, None).await.unwrap_err();
assert!(error.contains("no created index name"), "{error}");
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_create_index_rejects_key_options_before_agent_dispatch() {
let (state, _directory) = legacy_mongo_state("unexpected", serde_json::json!({}), serde_json::json!({})).await;
let error =
mongo_create_index_core(&state, "legacy", "app", "users", r#"{"email":1}"#, Some(r#"{"key":{"other":1}}"#))
.await
.unwrap_err();
assert!(error.contains("cannot contain \"key\""), "{error}");
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_create_index_rejects_an_empty_name_before_agent_dispatch() {
let (state, _directory) = legacy_mongo_state("unexpected", serde_json::json!({}), serde_json::json!({})).await;
let error = mongo_create_index_core(&state, "legacy", "app", "users", r#"{"email":1}"#, Some(r#"{"name":""}"#))
.await
.unwrap_err();
assert!(error.contains("non-empty string"), "{error}");
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_drop_index_routes_legacy_connections_to_the_agent() {
let (state, _directory) = legacy_mongo_state(
"drop_indexes",
serde_json::json!({
"database": "app",
"collection": "users",
"indexes_json": "\"email_1\"",
"single": true,
}),
serde_json::json!({ "affected_rows": 1, "dropped_names": ["email_1"] }),
)
.await;
let result =
mongo_drop_indexes_core(&state, "legacy", "app", "users", Some(r#""email_1""#), true).await.unwrap();
assert_eq!(result.dropped_names, ["email_1"]);
assert_eq!(result.affected_rows, 1);
assert!(result.failures.is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_drop_indexes_returns_partial_results_for_mongodb_34_agents() {
let (state, _directory) = legacy_mongo_state_with_server_version(
"drop_indexes",
serde_json::json!({
"database": "app",
"collection": "users",
"indexes_json": "\"email_1\"",
"single": true,
}),
serde_json::json!({ "affected_rows": 1, "dropped_names": ["email_1"] }),
"3.4.24",
)
.await;
let result =
mongo_drop_indexes_core(&state, "legacy", "app", "users", Some(r#"["email_1","missing_1"]"#), false)
.await
.unwrap();
assert_eq!(result.dropped_names, ["email_1"]);
assert_eq!(result.affected_rows, 1);
assert_eq!(result.failures.len(), 1);
assert_eq!(result.failures[0].name, "missing_1");
assert!(result.failures[0].message.contains("unexpected MongoDB RPC"));
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_drop_indexes_preserves_modern_agent_batch_failure_semantics() {
let (state, _directory) = legacy_mongo_state_with_server_error(
"drop_indexes",
serde_json::json!({
"database": "app",
"collection": "users",
"indexes_json": "[\"email_1\",\"missing_1\"]",
"single": false,
}),
"index not found; no indexes dropped",
"4.2.0",
)
.await;
let error =
mongo_drop_indexes_core(&state, "legacy", "app", "users", Some(r#"["email_1","missing_1"]"#), false)
.await
.unwrap_err();
assert!(error.contains("no indexes dropped"), "{error}");
}
#[cfg(unix)]
#[tokio::test]
async fn mongo_drop_indexes_rejects_the_default_id_index_before_agent_dispatch() {
let (state, _directory) = legacy_mongo_state("unexpected", serde_json::json!({}), serde_json::json!({})).await;
let error =
mongo_drop_indexes_core(&state, "legacy", "app", "users", Some(r#""_id_""#), true).await.unwrap_err();
assert!(error.contains("_id_"), "{error}");
}
}
+134 -4
View File
@@ -2200,10 +2200,8 @@ fn ensure_mcp_connection_change_allowed_in_tx(
Ok(())
}
fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> {
let config = config.clone().canonicalized();
let config_id = config.id.clone();
let mut sanitized = config.clone();
fn sanitized_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
let mut sanitized = config.clone().canonicalized();
sanitized.password = String::new();
scrub_transport_layer_secrets(&mut sanitized);
sanitized.redis_sentinel_password = String::new();
@@ -2212,6 +2210,13 @@ fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionC
scrub_mq_auth_secrets(&mut sanitized);
scrub_mq_token_signing_secret(&mut sanitized);
scrub_nacos_auth_secrets(&mut sanitized);
sanitized
}
fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> {
let config = config.clone().canonicalized();
let config_id = config.id.clone();
let sanitized = sanitized_connection_config(&config);
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
@@ -2433,6 +2438,65 @@ impl Storage {
.await
}
/// Update only the persisted driver identity for an existing connection.
/// This is used after runtime driver fallback and deliberately leaves all
/// other connection metadata and separately stored secrets untouched.
pub async fn save_connection_driver_profile(
&self,
expected_config: &ConnectionConfig,
driver_profile: Option<String>,
driver_label: Option<String>,
) -> Result<bool, String> {
let expected_config = sanitized_connection_config(expected_config);
let connection_id = expected_config.id.clone();
self.with_conn(move |conn| {
let Some(json) = conn
.query_row("SELECT config_json FROM connections WHERE id = ?1", [&connection_id], |row| {
row.get::<_, String>(0)
})
.optional()
.map_err(|error| error.to_string())?
else {
return Ok(false);
};
let current: ConnectionConfig = serde_json::from_str(&json).map_err(|error| error.to_string())?;
let mut current_identity = sanitized_connection_config(&current);
let mut expected_identity = expected_config.clone();
current_identity.note.clear();
current_identity.database_info = None;
expected_identity.note.clear();
expected_identity.database_info = None;
let identity_matches = current_identity == expected_identity
|| (current_identity.driver_profile == driver_profile
&& current_identity.driver_label == driver_label
&& {
current_identity.driver_profile = expected_identity.driver_profile.clone();
current_identity.driver_label = expected_identity.driver_label.clone();
current_identity == expected_identity
});
if !identity_matches {
return Ok(false);
}
if current.driver_profile == driver_profile && current.driver_label == driver_label {
return Ok(true);
}
let mut updated = sanitized_connection_config(&current);
updated.driver_profile = driver_profile;
updated.driver_label = driver_label;
let updated_json = serde_json::to_string(&updated).map_err(|error| error.to_string())?;
conn.execute(
"UPDATE connections SET config_json = ?1 WHERE id = ?2 AND config_json = ?3",
params![updated_json, connection_id, json],
)
.map(|updated| updated > 0)
.map_err(|error| error.to_string())
})
.await
}
pub async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
let rows: Vec<(String, String)> = self
.with_conn(|conn| {
@@ -4357,6 +4421,72 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn save_connection_driver_profile_updates_only_the_target_metadata() {
let path = temp_db_path("connection-driver-profile");
let storage = Storage::open(&path).await.unwrap();
let target = mq_connection("target", "target-secret");
let untouched = mq_connection("untouched", "untouched-secret");
storage.save_connections(&[target.clone(), untouched.clone()]).await.unwrap();
assert!(storage
.save_connection_driver_profile(
&target,
Some("mongodb-legacy".to_string()),
Some("MongoDB (Legacy)".to_string()),
)
.await
.unwrap());
let mut wrong_type = untouched.clone();
wrong_type.db_type = DatabaseType::MongoDb;
assert!(!storage
.save_connection_driver_profile(&wrong_type, Some("mongodb-legacy".to_string()), None,)
.await
.unwrap());
let mut missing = wrong_type;
missing.id = "missing".to_string();
assert!(!storage
.save_connection_driver_profile(&missing, Some("mongodb-legacy".to_string()), None)
.await
.unwrap());
let loaded = storage.load_connections().await.unwrap();
let target = loaded.iter().find(|config| config.id == "target").unwrap();
assert_eq!(target.driver_profile.as_deref(), Some("mongodb-legacy"));
assert_eq!(target.driver_label.as_deref(), Some("MongoDB (Legacy)"));
assert_eq!(mq_token(target), Some("target-secret"));
assert_eq!(loaded.iter().find(|config| config.id == "untouched"), Some(&untouched));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn save_connection_driver_profile_rejects_a_stale_connection_config() {
let path = temp_db_path("connection-driver-profile-stale");
let storage = Storage::open(&path).await.unwrap();
let original = mq_connection("target", "target-secret");
storage.save_connections(std::slice::from_ref(&original)).await.unwrap();
let mut replacement = original.clone();
replacement.host = "replacement.example.com".to_string();
replacement.name = "Replacement".to_string();
storage.save_connections(std::slice::from_ref(&replacement)).await.unwrap();
assert!(!storage
.save_connection_driver_profile(
&original,
Some("mongodb-legacy".to_string()),
Some("MongoDB (Legacy)".to_string()),
)
.await
.unwrap());
let loaded = storage.load_connections().await.unwrap();
assert_eq!(loaded, vec![replacement]);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn save_connections_moves_mq_auth_token_to_secret_table_and_restores_it() {
let path = temp_db_path("mq-token-secrets");
+62 -10
View File
@@ -596,10 +596,9 @@ impl DbxBackend for LocalBackend {
*single,
)
.await?;
let rows = result.dropped_names.into_iter().map(|name| vec![Value::String(name)]).collect::<Vec<_>>();
Ok(query_result(
if rows.is_empty() { Vec::new() } else { vec!["name".to_string()] },
rows,
Ok(mongo_drop_indexes_query_result(
result.dropped_names,
result.failures.into_iter().map(|failure| (failure.name, failure.message)).collect(),
result.affected_rows,
))
}
@@ -1226,13 +1225,21 @@ impl DbxBackend for WebBackend {
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(|name| vec![Value::String(name.to_string())])
.map(str::to_string)
.collect::<Vec<_>>();
Ok(query_result(
if dropped_names.is_empty() { Vec::new() } else { vec!["name".to_string()] },
dropped_names,
affected_rows_from_value(&value),
))
let failures = value
.get("failures")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|failure| {
Some((
failure.get("name")?.as_str()?.to_string(),
failure.get("message")?.as_str()?.to_string(),
))
})
.collect::<Vec<_>>();
Ok(mongo_drop_indexes_query_result(dropped_names, failures, affected_rows_from_value(&value)))
}
MongoCommand::DropCollection { collection } => {
self.request(
@@ -1362,6 +1369,28 @@ fn affected_query_result(affected_rows: u64) -> dbx_core::db::QueryResult {
query_result(Vec::new(), Vec::new(), affected_rows)
}
fn mongo_drop_indexes_query_result(
dropped_names: Vec<String>,
failures: Vec<(String, String)>,
affected_rows: u64,
) -> dbx_core::db::QueryResult {
if failures.is_empty() {
let rows = dropped_names.into_iter().map(|name| vec![Value::String(name)]).collect::<Vec<_>>();
return query_result(if rows.is_empty() { Vec::new() } else { vec!["name".to_string()] }, rows, affected_rows);
}
let mut rows = dropped_names
.into_iter()
.map(|name| vec![Value::String(name), Value::String("dropped".to_string()), Value::Null])
.collect::<Vec<_>>();
rows.extend(
failures.into_iter().map(|(name, message)| {
vec![Value::String(name), Value::String("failed".to_string()), Value::String(message)]
}),
);
query_result(vec!["name".to_string(), "status".to_string(), "message".to_string()], rows, affected_rows)
}
fn mongo_documents_query_result(documents: Vec<Value>) -> dbx_core::db::QueryResult {
if documents.is_empty() {
return query_result(Vec::new(), Vec::new(), 0);
@@ -1534,6 +1563,29 @@ mod tests {
assert!(parse_database_type("unknown").is_err());
}
#[test]
fn mongo_drop_indexes_query_result_preserves_partial_failures() {
let result = mongo_drop_indexes_query_result(
vec!["email_1".to_string()],
vec![("missing_1".to_string(), "index not found".to_string())],
1,
);
assert_eq!(result.columns, ["name", "status", "message"]);
assert_eq!(
result.rows,
[
vec![Value::String("email_1".to_string()), Value::String("dropped".to_string()), Value::Null],
vec![
Value::String("missing_1".to_string()),
Value::String("failed".to_string()),
Value::String("index not found".to_string()),
],
]
);
assert_eq!(result.affected_rows, 1);
}
#[tokio::test]
async fn local_backend_uses_desktop_plugin_directory() {
let data_dir = tempfile::tempdir().unwrap();
+158 -6
View File
@@ -3,13 +3,16 @@ use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo};
use dbx_core::connection::{AppState, PoolKind};
use dbx_core::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType};
use serde::Deserialize;
use crate::error::AppError;
use crate::state::WebState;
const MONGO_LEGACY_DRIVER_PROFILE: &str = "mongodb-legacy";
const MONGO_LEGACY_DRIVER_LABEL: &str = "MongoDB (Legacy)";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectRequest {
@@ -69,6 +72,86 @@ fn is_connection_info_capability_unsupported(error: &str) -> bool {
&& (error.contains("unsupported") || error.contains("unknown method") || error.contains("method not found"))
}
fn mark_mongo_legacy_driver(config: &mut ConnectionConfig) -> bool {
if config.db_type != DatabaseType::MongoDb {
return false;
}
let changed = config.driver_profile.as_deref() != Some(MONGO_LEGACY_DRIVER_PROFILE)
|| config.driver_label.as_deref() != Some(MONGO_LEGACY_DRIVER_LABEL);
config.driver_profile = Some(MONGO_LEGACY_DRIVER_PROFILE.to_string());
config.driver_label = Some(MONGO_LEGACY_DRIVER_LABEL.to_string());
changed
}
fn mongo_fallback_config_matches(current: &ConnectionConfig, expected: &ConnectionConfig) -> bool {
let mut current = current.clone();
let mut expected = expected.clone();
current.note.clear();
current.database_info = None;
expected.note.clear();
expected.database_info = None;
if current == expected {
return true;
}
if current.driver_profile.as_deref() != Some(MONGO_LEGACY_DRIVER_PROFILE)
|| current.driver_label.as_deref() != Some(MONGO_LEGACY_DRIVER_LABEL)
{
return false;
}
current.driver_profile = expected.driver_profile.clone();
current.driver_label = expected.driver_label.clone();
current == expected
}
async fn apply_mongo_legacy_driver_profile(state: &WebState, config: &ConnectionConfig) -> Result<(), AppError> {
if config.db_type != DatabaseType::MongoDb {
return Ok(());
}
// Draft and one-time connections have no durable profile to update.
let persisted = if config.one_time {
true
} else {
state
.app
.storage
.save_connection_driver_profile(
config,
Some(MONGO_LEGACY_DRIVER_PROFILE.to_string()),
Some(MONGO_LEGACY_DRIVER_LABEL.to_string()),
)
.await
.map_err(AppError::from)?
};
if !persisted {
return Ok(());
}
let mut runtime_configs = state.app.configs.write().await;
if let Some(current) =
runtime_configs.get_mut(&config.id).filter(|current| mongo_fallback_config_matches(current, config))
{
mark_mongo_legacy_driver(current);
}
Ok(())
}
/// The core connector can choose the Legacy Agent after a native MongoDB
/// handshake failure. Keep Web's runtime and saved profile aligned so the UI
/// does not expose native-only collection rename for that session.
async fn sync_mongo_legacy_driver_fallback(state: &WebState, config: &ConnectionConfig) -> Result<(), AppError> {
if config.db_type != DatabaseType::MongoDb {
return Ok(());
}
let uses_legacy_agent = {
let connections = state.app.connections.read().await;
matches!(connections.get(&config.id), Some(PoolKind::Agent(_)))
};
if !uses_legacy_agent {
return Ok(());
}
apply_mongo_legacy_driver_profile(state, config).await
}
async fn run_temporary_connection_test(
app: &Arc<AppState>,
config: ConnectionConfig,
@@ -143,6 +226,11 @@ pub async fn connect_db(
app.configs.write().await.insert(connection_id.clone(), config.clone());
app.get_or_create_pool_for_connection_attempt(&connection_id, None, attempt).await.map_err(AppError::from)?;
if let Err(error) = sync_mongo_legacy_driver_fallback(&state, &config).await {
app.remove_connection_pools_detached(&connection_id).await;
app.reset_connection_transport_for_config(&connection_id, &config).await;
return Err(error);
}
Ok(Json(connection_id))
}
@@ -391,10 +479,10 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection
#[cfg(test)]
mod tests {
use super::{
connect_db, connection_final_proxy_port, disconnect_db, load_connections, mcp_add_connection,
mcp_remove_connection, save_connection_database_info, save_connections, test_connection,
test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest,
apply_mongo_legacy_driver_profile, connect_db, connection_final_proxy_port, disconnect_db, load_connections,
mark_mongo_legacy_driver, mcp_add_connection, mcp_remove_connection, save_connection_database_info,
save_connections, test_connection, test_connection_with_info, ConnectRequest, DisconnectRequest,
McpAddConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest,
};
use crate::state::WebState;
use axum::extract::State;
@@ -481,6 +569,70 @@ mod tests {
config
}
#[test]
fn mongo_legacy_marker_updates_only_mongodb_profiles() {
let mut mongo = sqlite_config("mongo", "");
mongo.db_type = DatabaseType::MongoDb;
mongo.driver_profile = Some("legacy".to_string());
assert!(mark_mongo_legacy_driver(&mut mongo));
assert_eq!(mongo.driver_profile.as_deref(), Some("mongodb-legacy"));
assert_eq!(mongo.driver_label.as_deref(), Some("MongoDB (Legacy)"));
assert!(!mark_mongo_legacy_driver(&mut mongo));
let mut sqlite = sqlite_config("sqlite", ":memory:");
assert!(!mark_mongo_legacy_driver(&mut sqlite));
assert_eq!(sqlite.driver_profile, None);
}
#[tokio::test]
async fn mongo_legacy_profile_sync_preserves_unrelated_saved_connections() {
let (state, dir) = test_web_state().await;
let mut mongo = sqlite_config("mongo", "");
mongo.db_type = DatabaseType::MongoDb;
let other = sqlite_config("other", ":memory:");
state.app.storage.save_connections(&[mongo.clone(), other.clone()]).await.unwrap();
let mut current = mongo.clone();
current.note = "Updated while connecting".to_string();
state.app.configs.write().await.insert(current.id.clone(), current);
apply_mongo_legacy_driver_profile(&state, &mongo).await.unwrap();
let runtime = state.app.configs.read().await.get(&mongo.id).cloned().unwrap();
assert_eq!(runtime.note, "Updated while connecting");
assert_eq!(runtime.driver_profile.as_deref(), Some("mongodb-legacy"));
assert_eq!(runtime.driver_label.as_deref(), Some("MongoDB (Legacy)"));
let saved = state.app.storage.load_connections().await.unwrap();
assert_eq!(saved.len(), 2);
assert_eq!(
saved.iter().find(|config| config.id == mongo.id).and_then(|config| config.driver_profile.as_deref()),
Some("mongodb-legacy")
);
assert!(saved.iter().any(|config| config.id == other.id));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn mongo_legacy_profile_sync_does_not_overwrite_a_replacement_connection() {
let (state, dir) = test_web_state().await;
let mut original = sqlite_config("mongo", "");
original.db_type = DatabaseType::MongoDb;
state.app.storage.save_connections(std::slice::from_ref(&original)).await.unwrap();
let mut replacement = original.clone();
replacement.host = "replacement.example.com".to_string();
replacement.name = "Replacement MongoDB".to_string();
state.app.storage.save_connections(std::slice::from_ref(&replacement)).await.unwrap();
state.app.configs.write().await.insert(replacement.id.clone(), replacement.clone());
apply_mongo_legacy_driver_profile(&state, &original).await.unwrap();
assert_eq!(state.app.configs.read().await.get(&replacement.id), Some(&replacement));
assert_eq!(state.app.storage.load_connections().await.unwrap(), vec![replacement]);
let _ = std::fs::remove_dir_all(dir);
}
async fn test_web_state() -> (Arc<WebState>, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("dbx-web-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
+38 -2
View File
@@ -336,8 +336,11 @@ pub async fn create_database(
pub async fn drop_database(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
Json(req): Json<MongoCollectionRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
super::mcp_policy::ensure_dangerous_write(&state, &headers, &req.connection_id, &req.database, "Drop database")
.await?;
ensure_writable(&state.app, &req.connection_id, "Drop database").await?;
dbx_core::mongo_ops::mongo_drop_database_core(&state.app, &req.connection_id, &req.database)
.await
@@ -796,9 +799,13 @@ async fn ensure_find_one_write_policy(
#[cfg(test)]
mod tests {
use super::ensure_find_one_write_policy;
use super::{drop_database, ensure_find_one_write_policy, MongoCollectionRequest};
use crate::state::WebState;
use axum::http::{HeaderMap, HeaderValue};
use axum::{
extract::State,
http::{HeaderMap, HeaderValue},
Json,
};
use dbx_core::connection::AppState;
use dbx_core::models::connection::ConnectionConfig;
use dbx_core::storage::{McpGlobalPolicy, Storage};
@@ -901,4 +908,33 @@ mod tests {
drop(state);
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn drop_database_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 = drop_database(
State(state.clone()),
mcp_headers(),
Json(MongoCollectionRequest { connection_id: connection.id, database: "app".to_string() }),
)
.await
.unwrap_err();
assert!(error.message.starts_with("SQL_BLOCKED:"), "{}", error.message);
drop(state);
let _ = std::fs::remove_dir_all(dir);
}
}
+12
View File
@@ -9,6 +9,7 @@ import {
mongoCountToQueryResult,
mongoDistinctToQueryResult,
mongoDocumentsToQueryResult,
mongoDroppedIndexesToQueryResult,
mongoFindLogicalTotal,
mongoIndexesToQueryResult,
normalizeRustMongoCommand,
@@ -1055,6 +1056,17 @@ test("mongoDocumentsToQueryResult keeps aligned extended documents for copying",
assert.equal(mongoDocumentsToQueryResult(documents, 5, 1, []).mongo_copy_documents, undefined);
});
test("mongoDroppedIndexesToQueryResult exposes partial failures", () => {
const result = mongoDroppedIndexesToQueryResult(["email_1"], 5, [{ name: "missing_1", message: "index not found" }]);
assert.deepEqual(result.columns, ["name", "status", "message"]);
assert.deepEqual(result.rows, [
["email_1", "dropped", null],
["missing_1", "failed", "index not found"],
]);
assert.equal(result.affected_rows, 1);
});
test("mongoDocumentsToQueryResult preserves an inexact total marker", () => {
const result = mongoDocumentsToQueryResult([{ _id: "1" }], 5, 10_000_000, undefined, false);
+26 -8
View File
@@ -114,13 +114,7 @@ test("mongo sidebar mutations share the production-gated runMongoSidebarMutation
assert.match(shellSource, /onSuccess\(executed\.result\)/, "onSuccess must receive the unboxed execute result");
const source = readSource("apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts");
for (const name of [
"confirmRenameMongoCollection",
"confirmDropMongoCollection",
"confirmDropMongoIndex",
"confirmDropAllMongoIndexes",
"confirmDropMongoDatabase",
] as const) {
for (const name of ["confirmCreateMongoIndex", "confirmRenameMongoCollection", "confirmDropMongoCollection", "confirmDropMongoIndex", "confirmDropAllMongoIndexes", "confirmDropMongoDatabase"] as const) {
const body = functionBody(source, name);
assert.match(body, /runMongoSidebarMutation/, `${name} must use the shared mongo mutation shell`);
assert.ok(body.includes("production.sourceSidebar"), `${name} should label the confirmation source`);
@@ -129,7 +123,31 @@ test("mongo sidebar mutations share the production-gated runMongoSidebarMutation
assert.ok(source.includes("api.mongoRenameCollection"), "mongo rename should still call the rename API");
assert.ok(source.includes("api.mongoDropDatabase"), "mongo drop database should still call the drop API");
const dropDatabaseBody = functionBody(readSource("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue"), "confirmDropDatabase");
const hostSource = readSource("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue");
const dropDatabaseBody = functionBody(hostSource, "confirmDropDatabase");
assert.match(dropDatabaseBody, /confirmDropMongoDatabase/, "host drop-database confirm should delegate mongo to the mutation runtime");
assert.ok(!dropDatabaseBody.includes("api.mongoDropDatabase"), "host drop-database confirm should not call mongo APIs directly");
const mongoCollectionMenuBody = functionBody(hostSource, "buildSpecialSidebarMenu");
assert.match(mongoCollectionMenuBody, /action:\s*dropAllMongoIndexes/, "collection context menu must keep the drop-all-indexes entrypoint");
const batchDropBody = functionBody(hostSource, "confirmBatchDrop");
assert.match(batchDropBody, /catch\s*\([^)]*\)\s*\{[\s\S]*?failedCount \+= groupTargets\.length/, "cross-collection index deletion must retain earlier successes after a group failure");
assert.match(batchDropBody, /droppedCount === 0[\s\S]*?throw firstGroupError/, "an entirely failed cross-collection request must preserve its original error");
assert.match(batchDropBody, /finally\s*\{[\s\S]*?refreshMongoIndexTreeAfterMutation/, "every attempted index group must force a metadata refresh");
});
test("mongo data-grid index deletions stay production-gated and refresh metadata", () => {
const source = readSource("apps/desktop/src/components/grid/DataGrid.vue");
for (const name of ["confirmDropMongoIndex", "confirmDropAllMongoIndexes"] as const) {
const body = functionBody(source, name);
assert.match(body, /runMongoMutation/, `${name} must use the shared production-gated mongo mutation shell`);
assert.match(body, /api\.mongoDropIndexes/, `${name} should still call the MongoDB API`);
assert.match(body, /finally\s*\{[\s\S]*?refreshMongoIndexMetadataAfterMutation/, `${name} must refresh index metadata even when deletion fails`);
assert.ok(body.indexOf("runMongoMutation") < body.indexOf("api.mongoDropIndexes"), `production confirmation must wrap ${name}`);
}
const refreshBody = functionBody(source, "refreshMongoIndexMetadataAfterMutation");
assert.match(refreshBody, /reloadIndexes/, "data-grid metadata must refresh after index deletion");
assert.match(refreshBody, /refreshLoadedMongoIndexes/, "the loaded sidebar index tree must refresh after data-grid deletion");
assert.match(functionBody(source, "confirmDropAllMongoIndexes"), /api\.mongoDropIndexes\([^;]*undefined,\s*false\)/, "drop all must use MongoDB wildcard semantics instead of the currently loaded index nodes");
assert.match(source, /@click="requestDropAllMongoIndexes"/, "data-grid index drawer must keep the drop-all-indexes action");
assert.match(source, /v-model:open="showDropAllMongoIndexesConfirm"/, "data-grid drop-all-indexes action must keep its confirmation dialog");
});
+55 -4
View File
@@ -4462,19 +4462,59 @@ test("mongo dropIndex execution uses the dedicated drop-indexes endpoint", async
}
});
test("mongo dropIndexes execution returns dropped index names", async () => {
test("mongo dropIndexes execution exposes partial failures and refreshes loaded index metadata", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
let dropIndexesBody: any;
let indexRefreshRequested = false;
connectionStore.addEphemeralConnection({
...conn("mongo-1"),
db_type: "mongodb",
port: 27017,
});
connectionStore.treeNodes.push({
id: "mongo-1",
label: "Mongo",
type: "connection",
connectionId: "mongo-1",
isExpanded: true,
children: [
{
id: "mongo-1:accounting",
label: "accounting",
type: "mongo-db",
connectionId: "mongo-1",
database: "accounting",
isExpanded: true,
children: [
{
id: "mongo-1:accounting:users",
label: "users",
type: "mongo-collection",
connectionId: "mongo-1",
database: "accounting",
isExpanded: true,
children: [
{
id: "mongo-1:accounting:users:__indexes",
label: "tree.indexes",
type: "group-indexes",
connectionId: "mongo-1",
database: "accounting",
tableName: "users",
isExpanded: false,
children: [],
},
],
},
],
},
],
});
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
@@ -4487,11 +4527,15 @@ test("mongo dropIndexes execution returns dropped index names", async () => {
}
if (url === "/api/mongo/drop-indexes") {
dropIndexesBody = JSON.parse(String(init?.body ?? "{}"));
return new Response(JSON.stringify({ dropped_names: ["a_1", "b_1"], affected_rows: 2 }), {
return new Response(JSON.stringify({ dropped_names: ["a_1"], affected_rows: 1, failures: [{ name: "b_1", message: "index not found" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.startsWith("/api/schema/indexes?")) {
indexRefreshRequested = true;
return Response.json([{ name: "_id_", columns: ["_id"], is_unique: true, is_primary: true }]);
}
return new Response("unexpected request", { status: 500 });
});
@@ -4506,8 +4550,15 @@ test("mongo dropIndexes execution returns dropped index names", async () => {
collection: "users",
single: false,
});
assert.deepEqual(tab?.result?.columns, ["name"]);
assert.deepEqual(tab?.result?.rows, [["a_1"], ["b_1"]]);
assert.deepEqual(tab?.result?.columns, ["name", "status", "message"]);
assert.deepEqual(tab?.result?.rows, [
["a_1", "dropped", null],
["b_1", "failed", "index not found"],
]);
assert.equal(indexRefreshRequested, true);
const indexGroup = connectionStore.treeNodes[0]?.children?.[0]?.children?.[0]?.children?.[0];
assert.equal(indexGroup?.isExpanded, false);
assert.deepEqual(indexGroup?.children?.map((node) => node.label), ["_id_ (_id)"]);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
+36 -14
View File
@@ -45,20 +45,18 @@ fn mark_mongo_legacy_driver(config: &mut ConnectionConfig) -> bool {
changed
}
async fn persist_mongo_legacy_driver_profile(state: &AppState, config: &ConnectionConfig) -> Result<(), String> {
async fn persist_mongo_legacy_driver_profile(state: &AppState, config: &ConnectionConfig) -> Result<bool, String> {
if config.one_time {
return Ok(());
return Ok(true);
}
let mut configs: Vec<ConnectionConfig> =
state.storage.load_connections().await?.into_iter().map(|config| config.canonicalized()).collect();
let Some(saved_config) = configs.iter_mut().find(|saved_config| saved_config.id == config.id) else {
return Ok(());
};
if !mark_mongo_legacy_driver(saved_config) {
return Ok(());
}
save_connection_configs(state, &configs).await
state
.storage
.save_connection_driver_profile(
config,
Some(MONGO_LEGACY_DRIVER_PROFILE.to_string()),
Some(MONGO_LEGACY_DRIVER_LABEL.to_string()),
)
.await
}
async fn test_agent_connection(
@@ -171,7 +169,8 @@ mod tests {
use super::load_connection_configs;
use super::{
connect_sqlite_from_config, gaussdb_m_jdbc_command_config, mark_mongo_legacy_driver,
mongo_legacy_connect_params, save_connection_configs, MONGO_LEGACY_DRIVER_LABEL, MONGO_LEGACY_DRIVER_PROFILE,
mongo_legacy_connect_params, persist_mongo_legacy_driver_profile, save_connection_configs,
MONGO_LEGACY_DRIVER_LABEL, MONGO_LEGACY_DRIVER_PROFILE,
};
use dbx_core::connection::{AppState, PoolKind};
use dbx_core::models::connection::{AttachedDatabaseConfig, ConnectionConfig, DatabaseType};
@@ -408,6 +407,29 @@ mod tests {
assert!(!mark_mongo_legacy_driver(&mut config));
}
#[tokio::test]
async fn persist_mongo_legacy_driver_profile_updates_only_the_target_connection() {
let dir = std::env::temp_dir().join(format!("dbx-tauri-mongo-profile-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
let mongo = mongodb_config();
let mut other = mongodb_config();
other.id = "other".to_string();
other.name = "Other MongoDB".to_string();
state.storage.save_connections(&[mongo.clone(), other.clone()]).await.unwrap();
persist_mongo_legacy_driver_profile(&state, &mongo).await.unwrap();
let saved = state.storage.load_connections().await.unwrap();
let updated = saved.iter().find(|config| config.id == mongo.id).unwrap();
assert_eq!(updated.driver_profile.as_deref(), Some(MONGO_LEGACY_DRIVER_PROFILE));
assert_eq!(updated.driver_label.as_deref(), Some(MONGO_LEGACY_DRIVER_LABEL));
assert_eq!(saved.iter().find(|config| config.id == other.id), Some(&other));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "sqlite-sqlcipher")]
#[tokio::test]
async fn sqlite_connect_from_config_uses_sqlcipher_key() {
@@ -1263,9 +1285,9 @@ pub async fn connect_db(
)
})?;
state.ensure_current_connection_attempt(&id, Some(attempt)).await?;
persist_mongo_legacy_driver_profile(state.inner(), &connected_config).await?;
mark_mongo_legacy_driver(&mut connected_config);
connected_db_config = metadata_connection_config(&connected_config);
persist_mongo_legacy_driver_profile(state.inner(), &connected_config).await?;
PoolKind::agent(client)
} else {
return Err(native_err);