mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:25:05 +00:00
feat(mongodb): improve sidebar index management
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
+102
@@ -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;
|
||||
|
||||
Vendored
+435
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}テーブルは失敗しました",
|
||||
|
||||
@@ -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}개 실패",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 张失败",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+121
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t);
|
||||
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(¤t);
|
||||
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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user