mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
@@ -62,6 +62,10 @@ export interface MongoVersionCommand {
|
||||
kind: "version";
|
||||
}
|
||||
|
||||
export interface MongoShowDatabasesCommand {
|
||||
kind: "showDatabases";
|
||||
}
|
||||
|
||||
export interface MongoCreateUserCommand {
|
||||
userJson: string;
|
||||
writeConcernJson?: string;
|
||||
@@ -91,6 +95,7 @@ export type MongoCommand =
|
||||
| ({ kind: "find" } & MongoFindCommand)
|
||||
| ({ kind: "findOne" } & MongoFindOneCommand)
|
||||
| MongoVersionCommand
|
||||
| MongoShowDatabasesCommand
|
||||
| ({ kind: "countDocuments" } & MongoCountDocumentsCommand)
|
||||
| ({ kind: "aggregate" } & MongoAggregateCommand)
|
||||
| ({ kind: "distinct" } & MongoDistinctCommand)
|
||||
@@ -619,6 +624,7 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
|
||||
// Keep the more specific readers ahead of generic write parsing so the
|
||||
// returned kind matches the result renderer we want to use downstream.
|
||||
const parsers: Array<(source: string) => MongoCommand | null> = [
|
||||
parseMongoShowDatabasesCommand,
|
||||
(source) => {
|
||||
const version = parseMongoVersionCommand(source);
|
||||
return version ?? null;
|
||||
@@ -691,6 +697,11 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseMongoShowDatabasesCommand(input: string): MongoShowDatabasesCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
return /^show\s+(?:dbs|databases)$/i.test(source) ? { kind: "showDatabases" } : null;
|
||||
}
|
||||
|
||||
export function splitMongoCommands(input: string): ParsedMongoCommand[] {
|
||||
return splitMongoCommandRanges(input).map(({ from: _from, to: _to, ...command }) => command);
|
||||
}
|
||||
@@ -786,6 +797,27 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM
|
||||
};
|
||||
}
|
||||
|
||||
export function mongoDatabasesToQueryResult(documents: unknown[], executionTimeMs: number, maxRows: number): QueryResult {
|
||||
const response = documents[0];
|
||||
const databases = isRecord(response) ? response.databases : undefined;
|
||||
if (!Array.isArray(databases)) throw new Error("MongoDB listDatabases response is missing the databases array.");
|
||||
if (!databases.every(isRecord)) {
|
||||
throw new Error("MongoDB listDatabases response contains an invalid database entry.");
|
||||
}
|
||||
|
||||
const rowLimit = Number.isFinite(maxRows) ? Math.max(1, Math.trunc(maxRows)) : databases.length;
|
||||
const rows = databases.slice(0, rowLimit).map((database) => [toCellValue(database.name), toCellValue(database.sizeOnDisk), toCellValue(database.empty)]);
|
||||
const truncated = rows.length < databases.length;
|
||||
return {
|
||||
columns: ["name", "sizeOnDisk", "empty"],
|
||||
rows,
|
||||
affected_rows: databases.length,
|
||||
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
|
||||
truncated,
|
||||
has_more: truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export function mongoDistinctToQueryResult(field: string, values: unknown[], executionTimeMs: number): QueryResult {
|
||||
return {
|
||||
columns: [field],
|
||||
@@ -1110,7 +1142,7 @@ function mongoTopLevelCommandLineStarts(segment: string): number[] {
|
||||
|
||||
function isMongoCommandLineStart(segment: string, index: number): boolean {
|
||||
const rest = segment.slice(index);
|
||||
return /^use\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest);
|
||||
return /^use\b/i.test(rest) || /^show\s+(?:dbs|databases)\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest);
|
||||
}
|
||||
|
||||
function pushMongoSegment(segments: MongoTextRange[], source: string, from: number, to: number) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
evaluateMongoWriteSafety,
|
||||
mongoCollectionStatsToQueryResult,
|
||||
mongoCountToQueryResult,
|
||||
mongoDatabasesToQueryResult,
|
||||
mongoDistinctToQueryResult,
|
||||
mongoCreateIndexToQueryResult,
|
||||
mongoDocumentsToQueryResult,
|
||||
@@ -4092,6 +4093,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
queryExecutionLog("info", "mongo:start", { traceId, commandCount: mongoCommands.length, sqlLength: sql.length });
|
||||
|
||||
const allResults: QueryResult[] = [];
|
||||
const mongoResultMaxRows = effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows);
|
||||
// Track the effective db as we walk the batch so later commands observe
|
||||
// earlier `use ...` statements in the same editor selection.
|
||||
let currentDatabase = executionTarget?.database ?? tab.database;
|
||||
@@ -4175,6 +4177,19 @@ export const useQueryStore = defineStore("query", () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "showDatabases": {
|
||||
queryExecutionLog("info", "mongo-show-databases:start", { traceId });
|
||||
const result = await api.mongoRunCommand(executionConnectionId, "admin", '{"listDatabases":1}', executionId);
|
||||
const queryResult = mongoDatabasesToQueryResult(result.documents, performance.now() - commandStartedAt, agentProtocolQueryResultMaxRows(mongoResultMaxRows));
|
||||
allResults.push(markQueryResultRowsRaw(annotateMongoResult(queryResult)));
|
||||
mongoEditTarget = undefined;
|
||||
queryExecutionLog("info", "mongo-show-databases:done", {
|
||||
traceId,
|
||||
databaseCount: queryResult.affected_rows,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "countDocuments": {
|
||||
queryExecutionLog("info", "mongo-count:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||||
const total = await api.mongoCountDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.mode, executionId);
|
||||
|
||||
@@ -8,6 +8,9 @@ use crate::document_ops::CollectionInfo;
|
||||
use crate::mongo_shell::MongoCommand;
|
||||
use crate::types::{IndexInfo, QueryResult};
|
||||
|
||||
pub const MONGO_SHOW_DATABASES_DATABASE: &str = "admin";
|
||||
pub const MONGO_SHOW_DATABASES_COMMAND_JSON: &str = r#"{"listDatabases":1}"#;
|
||||
|
||||
async fn ensure_document_pool(state: &AppState, connection_id: &str) -> Result<(), String> {
|
||||
state.get_or_create_pool(connection_id, None).await.map(|_| ())
|
||||
}
|
||||
@@ -177,6 +180,10 @@ pub async fn mongo_run_command_core(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mongo_show_databases_core(state: &AppState, connection_id: &str) -> Result<MongoDocumentResult, String> {
|
||||
mongo_run_command_core(state, connection_id, MONGO_SHOW_DATABASES_DATABASE, MONGO_SHOW_DATABASES_COMMAND_JSON).await
|
||||
}
|
||||
|
||||
pub async fn mongo_collection_stats_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
@@ -918,6 +925,10 @@ pub async fn execute_mongo_command_core(
|
||||
.await
|
||||
.map(|version| scalar_query_result("version", Value::String(version))),
|
||||
MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))),
|
||||
MongoCommand::ShowDatabases => {
|
||||
let result = mongo_show_databases_core(state, connection_id).await?;
|
||||
mongo_show_databases_query_result(result.documents, max_rows)
|
||||
}
|
||||
MongoCommand::RunCommand { command_json } => {
|
||||
let result = mongo_run_command_core(state, connection_id, database, command_json).await?;
|
||||
Ok(mongo_documents_query_result(result.documents))
|
||||
@@ -1233,6 +1244,41 @@ fn mongo_documents_query_result(documents: Vec<serde_json::Value>) -> QueryResul
|
||||
query_result(columns, rows, 0)
|
||||
}
|
||||
|
||||
pub fn mongo_show_databases_query_result(
|
||||
documents: Vec<serde_json::Value>,
|
||||
max_rows: usize,
|
||||
) -> Result<QueryResult, String> {
|
||||
use serde_json::Value;
|
||||
|
||||
let databases = documents
|
||||
.first()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|response| response.get("databases"))
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("MongoDB listDatabases response is missing the databases array")?;
|
||||
if databases.iter().any(|database| !database.is_object()) {
|
||||
return Err("MongoDB listDatabases response contains an invalid database entry".to_string());
|
||||
}
|
||||
|
||||
let total = databases.len();
|
||||
let rows = databases
|
||||
.iter()
|
||||
.take(max_rows.max(1))
|
||||
.map(|database| {
|
||||
let database = database.as_object().expect("database entries were validated above");
|
||||
["name", "sizeOnDisk", "empty"].map(|field| database.get(field).cloned().unwrap_or(Value::Null)).to_vec()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut query_result = query_result(
|
||||
vec!["name".to_string(), "sizeOnDisk".to_string(), "empty".to_string()],
|
||||
rows,
|
||||
u64::try_from(total).unwrap_or(u64::MAX),
|
||||
);
|
||||
query_result.truncated = query_result.rows.len() < total;
|
||||
query_result.has_more = query_result.truncated;
|
||||
Ok(query_result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1261,6 +1307,63 @@ mod tests {
|
||||
assert_eq!(limited.extended_documents.unwrap(), vec![serde_json::json!(1), serde_json::json!(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_databases_result_preserves_metadata_and_row_limit() {
|
||||
let result = MongoDocumentResult {
|
||||
documents: vec![serde_json::json!({
|
||||
"databases": [
|
||||
{"name": "admin", "sizeOnDisk": 40960, "empty": false},
|
||||
{"name": "app", "sizeOnDisk": 8192, "empty": true},
|
||||
{"name": "logs", "sizeOnDisk": 1024, "empty": false}
|
||||
],
|
||||
"totalSize": 50176,
|
||||
"ok": 1
|
||||
})],
|
||||
raw_documents: None,
|
||||
extended_documents: None,
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
};
|
||||
|
||||
let query_result = mongo_show_databases_query_result(result.documents, 2).unwrap();
|
||||
|
||||
assert_eq!(query_result.columns, ["name", "sizeOnDisk", "empty"]);
|
||||
assert_eq!(
|
||||
query_result.rows,
|
||||
[
|
||||
[serde_json::json!("admin"), serde_json::json!(40960), serde_json::json!(false)],
|
||||
[serde_json::json!("app"), serde_json::json!(8192), serde_json::json!(true)],
|
||||
]
|
||||
);
|
||||
assert_eq!(query_result.affected_rows, 3);
|
||||
assert!(query_result.truncated);
|
||||
assert!(query_result.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_databases_result_handles_empty_and_rejects_malformed_responses() {
|
||||
let empty = MongoDocumentResult {
|
||||
documents: vec![serde_json::json!({"databases": [], "ok": 1})],
|
||||
raw_documents: None,
|
||||
extended_documents: None,
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
};
|
||||
let query_result = mongo_show_databases_query_result(empty.documents, 100).unwrap();
|
||||
assert_eq!(query_result.columns, ["name", "sizeOnDisk", "empty"]);
|
||||
assert!(query_result.rows.is_empty());
|
||||
assert!(!query_result.truncated);
|
||||
|
||||
let malformed = MongoDocumentResult {
|
||||
documents: vec![serde_json::json!({"ok": 1})],
|
||||
raw_documents: None,
|
||||
extended_documents: None,
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
};
|
||||
assert!(mongo_show_databases_query_result(malformed.documents, 100).unwrap_err().contains("databases array"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
|
||||
#[cfg(unix)]
|
||||
@@ -1365,6 +1468,7 @@ EXPECTED_RESULT = json.loads({expected_result})
|
||||
CAPABILITIES = {capabilities}
|
||||
SERVER_VERSION = {server_version}
|
||||
EXPECTED_ERROR = {expected_error}
|
||||
expected_calls = 0
|
||||
|
||||
print(json.dumps({{"ready": True}}), flush=True)
|
||||
for line in sys.stdin:
|
||||
@@ -1382,6 +1486,10 @@ for line in sys.stdin:
|
||||
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
|
||||
expected_calls += 1
|
||||
if expected_calls > 1:
|
||||
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "error": {{"code": -1, "message": "duplicate 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:
|
||||
@@ -1477,6 +1585,37 @@ for line in sys.stdin:
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn mongo_show_databases_uses_one_admin_agent_command() {
|
||||
let expected_result = serde_json::json!({
|
||||
"documents": [{
|
||||
"databases": [{"name": "admin", "sizeOnDisk": 40960, "empty": false}],
|
||||
"ok": 1
|
||||
}],
|
||||
"total": 1,
|
||||
});
|
||||
let (state, _directory) = legacy_mongo_state_with_capabilities(
|
||||
"run_command",
|
||||
serde_json::json!({
|
||||
"database": "admin",
|
||||
"command_json": "{\"listDatabases\":1}",
|
||||
}),
|
||||
expected_result,
|
||||
&[AgentCapability::MongoRunCommand.as_str()],
|
||||
)
|
||||
.await;
|
||||
|
||||
let query_result =
|
||||
execute_mongo_command_core(&state, "legacy", "ignored-current-database", &MongoCommand::ShowDatabases, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query_result.columns, ["name", "sizeOnDisk", "empty"]);
|
||||
assert_eq!(query_result.rows.len(), 1);
|
||||
assert_eq!(query_result.rows[0][0], "admin");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn mongo_run_command_requires_an_explicit_legacy_agent_capability() {
|
||||
|
||||
@@ -8,6 +8,8 @@ pub enum MongoCommand {
|
||||
Version,
|
||||
#[serde(rename = "use")]
|
||||
Use { database: String },
|
||||
#[serde(rename = "showDatabases")]
|
||||
ShowDatabases,
|
||||
#[serde(rename = "runCommand")]
|
||||
RunCommand {
|
||||
#[serde(rename = "commandJson")]
|
||||
@@ -355,6 +357,10 @@ fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool {
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Result<MongoCommand, String> {
|
||||
let show_source = trim_mongo_outer_comments(input).trim_end_matches(';').trim();
|
||||
if parse_show_databases(show_source) {
|
||||
return Ok(MongoCommand::ShowDatabases);
|
||||
}
|
||||
let source = input.trim().trim_end_matches(';').trim();
|
||||
if source.eq_ignore_ascii_case("db.version()") {
|
||||
return Ok(MongoCommand::Version);
|
||||
@@ -645,6 +651,86 @@ pub fn parse(input: &str) -> Result<MongoCommand, String> {
|
||||
Err("Unsupported MongoDB shell command.".to_string())
|
||||
}
|
||||
|
||||
fn parse_show_databases(source: &str) -> bool {
|
||||
let mut words = source.split_whitespace();
|
||||
words.next().is_some_and(|word| word.eq_ignore_ascii_case("show"))
|
||||
&& words.next().is_some_and(|word| word.eq_ignore_ascii_case("dbs") || word.eq_ignore_ascii_case("databases"))
|
||||
&& words.next().is_none()
|
||||
}
|
||||
|
||||
fn trim_mongo_outer_comments(mut source: &str) -> &str {
|
||||
loop {
|
||||
source = source.trim_start();
|
||||
if source.starts_with("//") || source.starts_with("--") {
|
||||
source = source
|
||||
.char_indices()
|
||||
.find_map(|(index, character)| (character == '\n' || character == '\r').then_some(&source[index + 1..]))
|
||||
.unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if source.starts_with("/*") {
|
||||
let Some(end) = source.find("*/") else {
|
||||
return source;
|
||||
};
|
||||
source = &source[end + 2..];
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let source = source.trim_end();
|
||||
let mut body_end = 0;
|
||||
let mut quote = None;
|
||||
let mut escaped = false;
|
||||
let mut line_comment = false;
|
||||
let mut block_comment = false;
|
||||
let mut characters = source.char_indices().peekable();
|
||||
while let Some((index, character)) = characters.next() {
|
||||
let next = characters.peek().map(|(_, character)| *character);
|
||||
if line_comment {
|
||||
if character == '\n' || character == '\r' {
|
||||
line_comment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if block_comment {
|
||||
if character == '*' && next == Some('/') {
|
||||
block_comment = false;
|
||||
characters.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(delimiter) = quote {
|
||||
body_end = index + character.len_utf8();
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if character == '\\' {
|
||||
escaped = true;
|
||||
} else if character == delimiter {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character == '/' && next == Some('/')) || (character == '-' && next == Some('-')) {
|
||||
line_comment = true;
|
||||
characters.next();
|
||||
continue;
|
||||
}
|
||||
if character == '/' && next == Some('*') {
|
||||
block_comment = true;
|
||||
characters.next();
|
||||
continue;
|
||||
}
|
||||
if matches!(character, '"' | '\'' | '`') {
|
||||
quote = Some(character);
|
||||
}
|
||||
if !character.is_whitespace() {
|
||||
body_end = index + character.len_utf8();
|
||||
}
|
||||
}
|
||||
source[..body_end].trim_end()
|
||||
}
|
||||
|
||||
fn parse_collection_prefix(source: &str) -> Result<(String, usize), String> {
|
||||
if !source.get(..3).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db.")) {
|
||||
return Err("MongoDB command must start with db.<collection>.".to_string());
|
||||
@@ -1064,6 +1150,27 @@ mod tests {
|
||||
assert_eq!(validate_safety(&command, true, true, false), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_show_databases_aliases_as_read_only() {
|
||||
for source in [
|
||||
"show dbs",
|
||||
"SHOW DATABASES;",
|
||||
"ShOw DbS ;",
|
||||
"/* databases */ show dbs -- list",
|
||||
"// databases\nshow databases; /* list */",
|
||||
] {
|
||||
let command = parse(source).unwrap();
|
||||
assert_eq!(command, MongoCommand::ShowDatabases, "{source}");
|
||||
assert!(!command.is_mutating(), "{source}");
|
||||
assert!(!command.is_dangerous(), "{source}");
|
||||
assert_eq!(validate_safety(&command, false, false, true), Ok(()), "{source}");
|
||||
}
|
||||
|
||||
for source in ["show dbs extra", "show database", "show collections", "show"] {
|
||||
assert!(parse(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_run_command_shapes() {
|
||||
for source in [
|
||||
@@ -1230,6 +1337,8 @@ mod tests {
|
||||
let run_command = serde_json::to_value(parse("db.runCommand({ping: 1})").unwrap()).unwrap();
|
||||
assert_eq!(run_command["kind"], "runCommand");
|
||||
assert_eq!(run_command["commandJson"], r#"{"ping":1}"#);
|
||||
let show_databases = serde_json::to_value(parse("show dbs").unwrap()).unwrap();
|
||||
assert_eq!(show_databases["kind"], "showDatabases");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1006,6 +1006,23 @@ impl DbxBackend for WebBackend {
|
||||
Ok(scalar_query_result("version", Value::String(version)))
|
||||
}
|
||||
MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))),
|
||||
MongoCommand::ShowDatabases => {
|
||||
let result = self
|
||||
.request(
|
||||
reqwest::Method::POST,
|
||||
"/api/mongo/run-command",
|
||||
Some(json!({
|
||||
"connectionId": connection_id,
|
||||
"database": dbx_core::mongo_ops::MONGO_SHOW_DATABASES_DATABASE,
|
||||
"commandJson": dbx_core::mongo_ops::MONGO_SHOW_DATABASES_COMMAND_JSON,
|
||||
})),
|
||||
)
|
||||
.await?
|
||||
.json::<WebMongoDocuments>()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid MongoDB listDatabases response: {error}"))?;
|
||||
dbx_core::mongo_ops::mongo_show_databases_query_result(result.documents, 100)
|
||||
}
|
||||
MongoCommand::RunCommand { .. } => {
|
||||
Err("MongoDB runCommand is not available through the DBX MCP backend".to_string())
|
||||
}
|
||||
@@ -1863,6 +1880,86 @@ mod tests {
|
||||
assert_eq!(result.affected_rows, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_mongo_show_databases_uses_one_admin_read_command() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (request_sender, request_receiver) = mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let header_end = loop {
|
||||
let count = stream.read(&mut buffer).unwrap();
|
||||
request.extend_from_slice(&buffer[..count]);
|
||||
if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
break position + 4;
|
||||
}
|
||||
};
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then_some(value.trim())
|
||||
})
|
||||
.unwrap()
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
while request.len() < header_end + content_length {
|
||||
let count = stream.read(&mut buffer).unwrap();
|
||||
request.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap();
|
||||
request_sender
|
||||
.send((
|
||||
request.lines().next().unwrap().to_string(),
|
||||
request[header_end..header_end + content_length].to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response_body = r#"{"documents":[{"databases":[{"name":"admin","sizeOnDisk":40960,"empty":false},{"name":"app","sizeOnDisk":8192,"empty":true}],"ok":1}]}"#;
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let backend = WebBackend::new(format!("http://{address}"), String::new()).unwrap();
|
||||
backend.auth.lock().await.checked = true;
|
||||
let connection = new_connection_config(
|
||||
"legacy".to_string(),
|
||||
"Legacy MongoDB".to_string(),
|
||||
DatabaseType::MongoDb,
|
||||
"localhost".to_string(),
|
||||
27017,
|
||||
String::new(),
|
||||
String::new(),
|
||||
Some("app".to_string()),
|
||||
false,
|
||||
Some("mongodb-legacy".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
backend.connected.lock().await.insert(connection.id.clone(), connection.clone());
|
||||
|
||||
let result = backend.execute_mongo_command(&connection, "app", &MongoCommand::ShowDatabases).await.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
let (request_line, body) = request_receiver.recv().unwrap();
|
||||
assert_eq!(request_line, "POST /api/mongo/run-command HTTP/1.1");
|
||||
let request: Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(request["connectionId"], "legacy");
|
||||
assert_eq!(request["database"], "admin");
|
||||
assert_eq!(request["commandJson"], r#"{"listDatabases":1}"#);
|
||||
assert_eq!(result.columns, ["name", "sizeOnDisk", "empty"]);
|
||||
assert_eq!(result.rows.len(), 2);
|
||||
assert_eq!(result.affected_rows, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_mongo_find_explain_uses_explain_endpoint_and_preserves_options() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
+61
@@ -7,6 +7,7 @@ import {
|
||||
mongoAggregateWriteStage,
|
||||
mongoCollectionStatsToQueryResult,
|
||||
mongoCountToQueryResult,
|
||||
mongoDatabasesToQueryResult,
|
||||
mongoDistinctToQueryResult,
|
||||
mongoDocumentsToQueryResult,
|
||||
mongoDroppedIndexesToQueryResult,
|
||||
@@ -106,6 +107,66 @@ test("parseMongoRunCommand normalizes one non-empty command document", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("parseMongoCommand accepts read-only show-databases aliases exactly", () => {
|
||||
for (const source of ["show dbs", "SHOW DATABASES;", "/* databases */ show dbs -- list"]) {
|
||||
assert.deepEqual(parseMongoCommand(source)?.command, { kind: "showDatabases" }, source);
|
||||
}
|
||||
|
||||
for (const source of ["show dbs extra", "show database", "show collections", "show"]) {
|
||||
assert.equal(parseMongoCommand(source), null, source);
|
||||
}
|
||||
|
||||
assert.equal(evaluateMongoWriteSafety(parseMongoCommand("db.runCommand({listDatabases: 1})")!.command as MongoWriteCommand, {}).allowed, false);
|
||||
});
|
||||
|
||||
test("splitMongoCommands keeps show databases after a database switch", () => {
|
||||
assert.deepEqual(
|
||||
splitMongoCommands("use app\nshow databases").map(({ command }) => command.kind),
|
||||
["use", "showDatabases"],
|
||||
);
|
||||
});
|
||||
|
||||
test("mongoDatabasesToQueryResult preserves metadata and bounds rows", () => {
|
||||
assert.deepEqual(
|
||||
mongoDatabasesToQueryResult(
|
||||
[
|
||||
{
|
||||
databases: [
|
||||
{ name: "admin", sizeOnDisk: 40960, empty: false },
|
||||
{ name: "app", sizeOnDisk: { $numberLong: "8192" }, empty: true },
|
||||
{ name: "logs", sizeOnDisk: 1024, empty: false },
|
||||
],
|
||||
totalSize: 50176,
|
||||
ok: 1,
|
||||
},
|
||||
],
|
||||
4.4,
|
||||
2,
|
||||
),
|
||||
{
|
||||
columns: ["name", "sizeOnDisk", "empty"],
|
||||
rows: [
|
||||
["admin", 40960, false],
|
||||
["app", "8192", true],
|
||||
],
|
||||
affected_rows: 3,
|
||||
execution_time_ms: 4,
|
||||
truncated: true,
|
||||
has_more: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(mongoDatabasesToQueryResult([{ databases: [] }], 0, 10), {
|
||||
columns: ["name", "sizeOnDisk", "empty"],
|
||||
rows: [],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 0,
|
||||
truncated: false,
|
||||
has_more: false,
|
||||
});
|
||||
assert.throws(() => mongoDatabasesToQueryResult([{ ok: 1 }], 0, 10), /databases array/);
|
||||
});
|
||||
|
||||
test("runCommand support does not accept arbitrary Mongo shell JavaScript", () => {
|
||||
assert.deepEqual(
|
||||
splitMongoCommands(`for (let i = 0; i < 2; i += 1) {
|
||||
|
||||
+61
@@ -5073,6 +5073,67 @@ test("mongo runCommand execution follows use and preserves document results", as
|
||||
}
|
||||
});
|
||||
|
||||
test("mongo show dbs executes one bounded read-only admin command", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const runCommandBodies: any[] = [];
|
||||
|
||||
connectionStore.addEphemeralConnection({
|
||||
...conn("mongo-1"),
|
||||
db_type: "mongodb",
|
||||
port: 27017,
|
||||
});
|
||||
|
||||
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
|
||||
if (String(input) === "/api/mongo/run-command") {
|
||||
runCommandBodies.push(JSON.parse(String(init?.body ?? "{}")));
|
||||
return Response.json({
|
||||
documents: [
|
||||
{
|
||||
databases: [
|
||||
{ name: "admin", sizeOnDisk: 40960, empty: false },
|
||||
{ name: "app", sizeOnDisk: 8192, empty: true },
|
||||
],
|
||||
totalSize: 49152,
|
||||
ok: 1,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
});
|
||||
|
||||
try {
|
||||
const tabId = store.createTab("mongo-1", "accounting", "Query", "query", "");
|
||||
await store.executeTabSql(tabId, "show dbs");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
|
||||
assert.equal(runCommandBodies.length, 1);
|
||||
assert.deepEqual(runCommandBodies[0], {
|
||||
connectionId: "mongo-1",
|
||||
database: "admin",
|
||||
commandJson: '{"listDatabases":1}',
|
||||
executionId: runCommandBodies[0].executionId,
|
||||
});
|
||||
assert.equal(typeof runCommandBodies[0].executionId, "string");
|
||||
assert.deepEqual(tab?.result?.columns, ["name", "sizeOnDisk", "empty"]);
|
||||
assert.deepEqual(tab?.result?.rows, [
|
||||
["admin", 40960, false],
|
||||
["app", 8192, true],
|
||||
]);
|
||||
assert.equal(tab?.result?.affected_rows, 2);
|
||||
assert.equal(tab?.mongoEditTarget, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("mongo dropIndex execution uses the dedicated drop-indexes endpoint", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
|
||||
Reference in New Issue
Block a user