mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
feat(mongodb): support db.getSiblingDB()
This commit is contained in:
@@ -147,6 +147,7 @@ const COLLECTION_METHOD_BOOST: Record<(typeof COLLECTION_METHODS)[number]["label
|
||||
const DATABASE_METHODS = [
|
||||
{ label: "getCollection", detail: "Reference a collection by name", apply: 'getCollection("${}")' },
|
||||
{ label: "version", detail: "Show the MongoDB server version", apply: "version()" },
|
||||
{ label: "getSiblingDB", detail: "Run the next command against another database", apply: 'getSiblingDB("${database}")' },
|
||||
{ label: "stats", detail: "Show database statistics", apply: "stats()" },
|
||||
{ label: "serverStatus", detail: "Show server status", apply: "serverStatus()" },
|
||||
{ label: "createCollection", detail: "Create a collection", apply: 'createCollection("${name}")' },
|
||||
|
||||
@@ -97,7 +97,6 @@ const SUPPORTED_COLLECTION_METHODS = [
|
||||
|
||||
/** Database-level methods with a supported equivalent worth pointing at. */
|
||||
const DATABASE_METHOD_HINTS: Record<string, string> = {
|
||||
getSiblingDB: "switch databases with `use <database>` and then run the command against db.<collection>",
|
||||
adminCommand: "use db.runCommand({ ... })",
|
||||
getCollectionNames: "collections are listed in the sidebar",
|
||||
};
|
||||
@@ -112,7 +111,7 @@ const DATABASE_METHOD_SHAPES: Record<string, MongoMethodShape> = {
|
||||
runCommand: { expects: "one command document", roles: ["command"] },
|
||||
};
|
||||
|
||||
const SUPPORTED_DATABASE_METHODS = ["version", "stats", "serverStatus", "createCollection", "dropDatabase", "createUser", "runCommand", "getCollection"];
|
||||
const SUPPORTED_DATABASE_METHODS = ["version", "stats", "serverStatus", "createCollection", "dropDatabase", "createUser", "runCommand", "getCollection", "getSiblingDB"];
|
||||
|
||||
const SUPPORTED_VALUE_CONSTRUCTORS = ["ObjectId", "ISODate", "new Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"];
|
||||
|
||||
@@ -126,6 +125,17 @@ export function describeMongoCommandParseFailure(input: string): string {
|
||||
}
|
||||
|
||||
function diagnoseMongoCommand(source: string): string | null {
|
||||
if (SIBLING_DB_PREFIX.test(source)) {
|
||||
const sibling = splitSiblingDbPrefix(source);
|
||||
if (!sibling) return 'getSiblingDB() requires a database name string followed by a command, for example db.getSiblingDB("app").orders.find({}).';
|
||||
if (SIBLING_DB_PREFIX.test(`db${sibling.rest}`)) return "getSiblingDB() cannot be chained.";
|
||||
const inner = `db${sibling.rest}`;
|
||||
const parsedInner = parseMongoCommand(inner);
|
||||
if (parsedInner) {
|
||||
return parsedInner.command.kind === "use" || parsedInner.command.kind === "showDatabases" ? "getSiblingDB() must be followed by a collection or database method." : null;
|
||||
}
|
||||
return diagnoseMongoCommand(inner) ?? describeMongoCommandParseFailureBasic(inner);
|
||||
}
|
||||
if (/^show\s+(collections|tables)\b/i.test(source)) {
|
||||
return "show collections is not supported here; collections are listed in the sidebar. Only show dbs is supported.";
|
||||
}
|
||||
@@ -299,6 +309,7 @@ export interface MongoDistinctCommand {
|
||||
type MongoWriteKind = "runCommand" | "insert" | "update" | "replace" | "bulkWrite" | "delete" | "createIndex" | "createUser" | "dropIndex" | "dropIndexes" | "dropCollection" | "renameCollection" | "findOneAndUpdate" | "findOneAndReplace" | "findOneAndDelete";
|
||||
|
||||
export type MongoCommand =
|
||||
| { kind: "inDatabase"; database: string; command: MongoCommand }
|
||||
| ({ kind: "find" } & MongoFindCommand)
|
||||
| ({ kind: "findExplain" } & MongoFindExplainCommand)
|
||||
| ({ kind: "findOne" } & MongoFindOneCommand)
|
||||
@@ -1021,6 +1032,15 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
|
||||
},
|
||||
];
|
||||
|
||||
// `db.getSiblingDB("name").<command>` runs the wrapped command against that
|
||||
// database for this command only, unlike `use`.
|
||||
const sibling = splitSiblingDbPrefix(text);
|
||||
if (sibling) {
|
||||
const inner = parseMongoCommand(`db${sibling.rest}`);
|
||||
if (!inner || inner.command.kind === "use" || inner.command.kind === "showDatabases" || inner.command.kind === "inDatabase") return null;
|
||||
return { text, command: { kind: "inDatabase", database: sibling.database, command: inner.command } };
|
||||
}
|
||||
|
||||
for (const parse of parsers) {
|
||||
const command = parse(text);
|
||||
if (command) return { text, command };
|
||||
@@ -1029,6 +1049,23 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SIBLING_DB_PREFIX = /^db\s*\.\s*getSiblingDB\s*\(/;
|
||||
|
||||
/** The database named by a leading `db.getSiblingDB("…")`, and the text after the call, which must continue with `.`. */
|
||||
export function splitSiblingDbPrefix(source: string): { database: string; rest: string } | null {
|
||||
const match = SIBLING_DB_PREFIX.exec(source);
|
||||
if (!match) return null;
|
||||
const openIndex = match[0].length - 1;
|
||||
const closeIndex = findMatchingParen(source, openIndex);
|
||||
if (closeIndex < 0) return null;
|
||||
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
|
||||
const database = args.length === 1 ? /^(["'])([^"']+)\1$/.exec(args[0]!.trim())?.[2] : undefined;
|
||||
if (!database) return null;
|
||||
const rest = source.slice(closeIndex + 1);
|
||||
if (!rest.trimStart().startsWith(".")) return null;
|
||||
return { database, rest: rest.trimStart() };
|
||||
}
|
||||
|
||||
export function parseMongoShowDatabasesCommand(input: string): MongoShowDatabasesCommand | null {
|
||||
const source = input.trim().replace(/;$/, "").trim();
|
||||
return /^show\s+(?:dbs|databases)$/i.test(source) ? { kind: "showDatabases" } : null;
|
||||
|
||||
@@ -123,6 +123,27 @@ describe("queryStore MongoDB execution summary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("executes the authoritative inDatabase wrapper against the sibling database and restores the session database", async () => {
|
||||
mocks.mongoFindDocuments.mockResolvedValue({ documents: [{ _id: "1" }], extended_documents: [], total: 1, total_is_exact: true });
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("mongo-1", "app", "Query");
|
||||
|
||||
await store.executeTabSql(tabId, 'db.getSiblingDB("reports").events.find();\ndb.users.find()');
|
||||
|
||||
expect(mocks.mongoFindDocuments).toHaveBeenNthCalledWith(1, "mongo-1", "reports", "events", expect.any(Number), expect.any(Number), "{}", undefined, undefined, undefined, expect.any(String));
|
||||
expect(mocks.mongoFindDocuments).toHaveBeenNthCalledWith(2, "mongo-1", "app", "users", expect.any(Number), expect.any(Number), "{}", undefined, undefined, undefined, expect.any(String));
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.batchSqlExecution).toMatchObject({
|
||||
total: 2,
|
||||
completed: 2,
|
||||
items: [
|
||||
{ status: "success", statementIndex: 0 },
|
||||
{ status: "success", statementIndex: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the active execution ID to a pending explain and its cancellation", async () => {
|
||||
let rejectExplain!: (reason: Error) => void;
|
||||
mocks.mongoExplainFind.mockImplementation(
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
mongoScalarToQueryResult,
|
||||
mongoWriteToQueryResult,
|
||||
splitMongoCommandRanges,
|
||||
splitSiblingDbPrefix,
|
||||
type MongoAggregateSafetyOptions,
|
||||
} from "@/lib/mongo/mongoShellCommand";
|
||||
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
|
||||
@@ -6494,6 +6495,13 @@ export const useQueryStore = defineStore("query", () => {
|
||||
|
||||
for (const parsedCommand of mongoCommands) {
|
||||
let mongoCommand = parsedCommand.command;
|
||||
// db.getSiblingDB("x").<command>: target that database for this command only.
|
||||
const sessionDatabase = currentDatabase;
|
||||
const targetsSiblingDatabase = mongoCommand.kind === "inDatabase";
|
||||
if (mongoCommand.kind === "inDatabase") {
|
||||
currentDatabase = mongoCommand.database;
|
||||
mongoCommand = mongoCommand.command;
|
||||
}
|
||||
const sourceStatement = parsedCommand.text;
|
||||
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + parsedCommand.from, to: options.sourceOffset + parsedCommand.to };
|
||||
const commandStartedAt = performance.now();
|
||||
@@ -6508,10 +6516,21 @@ export const useQueryStore = defineStore("query", () => {
|
||||
// The frontend parser remains responsible for editor ranges, while
|
||||
// dbx-core is authoritative for command semantics at execution time.
|
||||
mongoCommand = await api.mongoParseShellCommand(sourceStatement);
|
||||
// The authoritative parse keeps the `db.getSiblingDB("x").` wrapper;
|
||||
// re-apply the same one-command database override before dispatch.
|
||||
if (mongoCommand.kind === "inDatabase") {
|
||||
currentDatabase = mongoCommand.database;
|
||||
mongoCommand = mongoCommand.command;
|
||||
}
|
||||
switch (mongoCommand.kind) {
|
||||
case "find": {
|
||||
queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
|
||||
const pagePlan = planMongoFindPagination(sourceStatement, mongoCommand, options?.pagination?.offset ?? 0, normalizeResultPageSize(options?.pagination?.limit ?? settingsStore.editorSettings.pageSize));
|
||||
// Pagination planning parses `db.<collection>.find(...)` directly;
|
||||
// strip a `db.getSiblingDB("x")` wrapper so the plan reflects the
|
||||
// wrapped command's own skip/limit chain.
|
||||
const siblingPrefix = splitSiblingDbPrefix(sourceStatement);
|
||||
const paginationSource = siblingPrefix ? `db${siblingPrefix.rest}` : sourceStatement;
|
||||
const pagePlan = planMongoFindPagination(paginationSource, mongoCommand, options?.pagination?.offset ?? 0, normalizeResultPageSize(options?.pagination?.limit ?? settingsStore.editorSettings.pageSize));
|
||||
if (!pagePlan) throw new Error(describeMongoCommandParseFailure(sourceStatement));
|
||||
// A stale request can point past an explicit .limit() bound. Keep
|
||||
// the backend call bounded so limit(0) cannot become unbounded.
|
||||
@@ -6820,6 +6839,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
// for the rest of the batch, matching the grouped-result UX.
|
||||
allResults.push(annotateMongoResult(toErrorResult(error)));
|
||||
mongoEditTarget = undefined;
|
||||
} finally {
|
||||
// A sibling-database command does not change the session database the way `use` does.
|
||||
if (targetsSiblingDatabase) currentDatabase = sessionDatabase;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8408,11 +8430,17 @@ export const useQueryStore = defineStore("query", () => {
|
||||
|
||||
if (effectiveDbType === "mongodb") {
|
||||
let mongoCommand;
|
||||
let exportDatabase = location.database;
|
||||
try {
|
||||
mongoCommand = await api.mongoParseShellCommand(sql);
|
||||
} catch {
|
||||
throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
|
||||
}
|
||||
if (mongoCommand.kind === "inDatabase") {
|
||||
// `db.getSiblingDB("x").c.find()` exports against the wrapped database.
|
||||
exportDatabase = mongoCommand.database;
|
||||
mongoCommand = mongoCommand.command;
|
||||
}
|
||||
if (mongoCommand.kind !== "find") throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
|
||||
|
||||
const pageLimit = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
|
||||
@@ -8422,14 +8450,18 @@ export const useQueryStore = defineStore("query", () => {
|
||||
let totalRows = typeof tab.resultTotalRowCount === "number" ? Math.min(tab.resultTotalRowCount, exportRowLimit) : null;
|
||||
const exportStartedAt = performance.now();
|
||||
const exportExecutionId = uuid();
|
||||
// Pagination planning parses `db.<collection>.find(...)` directly; strip a
|
||||
// `db.getSiblingDB("x")` wrapper the same way the execution loop does.
|
||||
const exportSiblingPrefix = splitSiblingDbPrefix(sql);
|
||||
const exportPaginationSource = exportSiblingPrefix ? `db${exportSiblingPrefix.rest}` : sql;
|
||||
|
||||
while (documents.length < exportRowLimit) {
|
||||
const remaining = exportRowLimit - documents.length;
|
||||
const plan = planMongoFindPagination(sql, mongoCommand, pageOffset, Math.min(pageLimit, remaining));
|
||||
const plan = planMongoFindPagination(exportPaginationSource, mongoCommand, pageOffset, Math.min(pageLimit, remaining));
|
||||
if (!plan) throw new Error(QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR);
|
||||
if (plan.requestLimit === 0) break;
|
||||
|
||||
const result = await api.mongoFindDocuments(location.connectionId, location.database, mongoCommand.collection, plan.requestSkip, plan.requestLimit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, exportExecutionId);
|
||||
const result = await api.mongoFindDocuments(location.connectionId, exportDatabase, mongoCommand.collection, plan.requestSkip, plan.requestLimit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, exportExecutionId);
|
||||
const pageDocuments = result.documents.slice(0, plan.requestLimit);
|
||||
documents.push(...pageDocuments);
|
||||
|
||||
|
||||
@@ -1049,6 +1049,9 @@ 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::InDatabase { database, command } => {
|
||||
Box::pin(execute_mongo_command_core(state, connection_id, database, command, max_rows)).await
|
||||
}
|
||||
MongoCommand::ShowDatabases => {
|
||||
let result = mongo_show_databases_core(state, connection_id).await?;
|
||||
mongo_show_databases_query_result(result.documents, max_rows)
|
||||
|
||||
@@ -90,6 +90,10 @@ pub enum MongoCommand {
|
||||
FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option<String> },
|
||||
#[serde(rename = "findOneAndDelete")]
|
||||
FindOneAndDelete { collection: String, filter: String, options: Option<String> },
|
||||
/// `db.getSiblingDB("name").<command>`: the wrapped command, run against `database`
|
||||
/// instead of the session's current database, for this command only.
|
||||
#[serde(rename = "inDatabase")]
|
||||
InDatabase { database: String, command: Box<MongoCommand> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -101,7 +105,18 @@ pub enum MongoSafetyError {
|
||||
}
|
||||
|
||||
impl MongoCommand {
|
||||
/// The database this command explicitly targets, when it does not use the current one.
|
||||
pub fn target_database(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::InDatabase { database, .. } => Some(database),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_mutating(&self) -> bool {
|
||||
if let Self::InDatabase { command, .. } = self {
|
||||
return command.is_mutating();
|
||||
}
|
||||
matches!(
|
||||
self,
|
||||
Self::RunCommand { .. }
|
||||
@@ -122,6 +137,9 @@ impl MongoCommand {
|
||||
}
|
||||
|
||||
pub fn is_dangerous(&self) -> bool {
|
||||
if let Self::InDatabase { command, .. } = self {
|
||||
return command.is_dangerous();
|
||||
}
|
||||
matches!(self, Self::RunCommand { .. } | Self::CreateUser { .. } | Self::DropCollection { .. })
|
||||
|| matches!(self, Self::DropIndexes { indexes: None, single: false, .. })
|
||||
|| matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
|
||||
@@ -129,6 +147,7 @@ impl MongoCommand {
|
||||
|
||||
pub fn has_empty_filter(&self) -> bool {
|
||||
match self {
|
||||
Self::InDatabase { command, .. } => command.has_empty_filter(),
|
||||
Self::Update { filter, .. }
|
||||
| Self::Replace { filter, .. }
|
||||
| Self::Delete { filter, .. }
|
||||
@@ -144,6 +163,7 @@ impl MongoCommand {
|
||||
|
||||
pub fn has_effectively_unbounded_filter(&self) -> bool {
|
||||
match self {
|
||||
Self::InDatabase { command, .. } => command.has_effectively_unbounded_filter(),
|
||||
Self::Update { filter, .. }
|
||||
| Self::Replace { filter, .. }
|
||||
| Self::Delete { filter, .. }
|
||||
@@ -538,6 +558,26 @@ pub fn parse(input: &str) -> Result<MongoCommand, String> {
|
||||
if let Some(database) = parse_use_database(source) {
|
||||
return Ok(MongoCommand::Use { database });
|
||||
}
|
||||
if source.starts_with("db")
|
||||
&& source[2..].trim_start().starts_with('.')
|
||||
&& source[2..].trim_start()[1..]
|
||||
.trim_start()
|
||||
.strip_prefix("getSiblingDB")
|
||||
.is_some_and(|after| after.trim_start().starts_with('('))
|
||||
&& split_sibling_db_prefix(source).is_none()
|
||||
{
|
||||
return Err("MongoDB getSiblingDB() requires a database name string followed by a command, for example db.getSiblingDB(\"app\").orders.find({}).".to_string());
|
||||
}
|
||||
if let Some((database, rest)) = split_sibling_db_prefix(source) {
|
||||
let command = parse(&format!("db{rest}"))?;
|
||||
return match command {
|
||||
MongoCommand::InDatabase { .. } => Err("MongoDB getSiblingDB() cannot be chained.".to_string()),
|
||||
MongoCommand::Use { .. } | MongoCommand::ShowDatabases => {
|
||||
Err("MongoDB getSiblingDB() must be followed by a collection or database method.".to_string())
|
||||
}
|
||||
command => Ok(MongoCommand::InDatabase { database, command: Box::new(command) }),
|
||||
};
|
||||
}
|
||||
// `db.stats()` and `db.serverStatus()` are the shell's shorthand for the
|
||||
// matching runCommand, so they execute through the same supported path.
|
||||
// `db.dropDatabase()` is the shell's shorthand for the dropDatabase command; running it
|
||||
@@ -1581,6 +1621,30 @@ fn legacy_update_options(value: Option<&String>) -> Result<(Option<String>, bool
|
||||
Ok((options, many))
|
||||
}
|
||||
|
||||
/// `db.getSiblingDB("name")` prefix: the database name and the text after the call,
|
||||
/// which must continue with `.` so the rest reads as a normal `db.` command.
|
||||
fn split_sibling_db_prefix(source: &str) -> Option<(String, &str)> {
|
||||
let rest = source.strip_prefix("db")?.trim_start().strip_prefix('.')?.trim_start().strip_prefix("getSiblingDB")?;
|
||||
let rest = rest.trim_start();
|
||||
if !rest.starts_with('(') {
|
||||
return None;
|
||||
}
|
||||
let close = matching_paren(rest, 0)?;
|
||||
let args = split_top_level(&rest[1..close]);
|
||||
if args.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let database = parse_string_arg(&args[0]).ok()?;
|
||||
if database.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let after = &rest[close + 1..];
|
||||
if !after.trim_start().starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
Some((database, after))
|
||||
}
|
||||
|
||||
fn parse_use_database(source: &str) -> Option<String> {
|
||||
let mut parts = source.split_whitespace();
|
||||
if !parts.next()?.eq_ignore_ascii_case("use") {
|
||||
@@ -2339,6 +2403,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_get_sibling_db_as_a_database_override() {
|
||||
// The exact command from #3936.
|
||||
let command = parse(
|
||||
r#"db.getSiblingDB("iam_account").getCollection("user").find({_id: NumberLong('144115205316939462')}).sort({phone: 1}).limit(21);"#,
|
||||
)
|
||||
.unwrap();
|
||||
let MongoCommand::InDatabase { database, command: inner } = &command else {
|
||||
panic!("expected an InDatabase wrapper");
|
||||
};
|
||||
assert_eq!(database, "iam_account");
|
||||
assert!(matches!(**inner, MongoCommand::Find { limit: 21, .. }));
|
||||
assert_eq!(command.target_database(), Some("iam_account"));
|
||||
assert!(!command.is_mutating());
|
||||
|
||||
// Database-level commands and odd spacing work too; safety delegates to the wrapped command.
|
||||
assert!(matches!(parse(r#"db . getSiblingDB( "x" ) . stats()"#).unwrap(), MongoCommand::InDatabase { .. }));
|
||||
let delete = parse(r#"db.getSiblingDB('x').c.deleteMany({})"#).unwrap();
|
||||
assert!(delete.is_mutating());
|
||||
assert!(delete.has_empty_filter() && delete.has_effectively_unbounded_filter());
|
||||
assert_eq!(validate_safety(&delete, true, false, false), Err(MongoSafetyError::EmptyFilter));
|
||||
|
||||
let serialized = serde_json::to_value(parse(r#"db.getSiblingDB("x").c.find({})"#).unwrap()).unwrap();
|
||||
assert_eq!(serialized["kind"], "inDatabase");
|
||||
assert_eq!(serialized["database"], "x");
|
||||
assert_eq!(serialized["command"]["kind"], "find");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_get_sibling_db() {
|
||||
for (source, expected) in [
|
||||
(r#"db.getSiblingDB("x")"#, "followed by a command"),
|
||||
(r#"db.getSiblingDB("x").getSiblingDB("y").c.find({})"#, "cannot be chained"),
|
||||
(r#"db.getSiblingDB("").c.find({})"#, "requires a database name string"),
|
||||
(r#"db.getSiblingDB(1).c.find({})"#, "requires a database name string"),
|
||||
(r#"db.getSiblingDB("x", "y").c.find({})"#, "requires a database name string"),
|
||||
(r#"db.getSiblingDB("x").use y"#, "collection method is required"),
|
||||
] {
|
||||
let error = parse(source).unwrap_err();
|
||||
assert!(error.contains(expected), "{source}\n expected: {expected}\n got: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_misreport_get_sibling_db_prefixed_methods() {
|
||||
// A method whose name merely starts with `getSiblingDB` must fall through
|
||||
// to the generic unsupported-method error, not the wrapper-specific one.
|
||||
let error = parse(r#"db.getSiblingDBX("x").c.find({})"#).unwrap_err();
|
||||
assert!(!error.contains("getSiblingDB()"), "got: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_show_databases_aliases_as_read_only() {
|
||||
for source in [
|
||||
|
||||
@@ -1638,6 +1638,9 @@ impl DbxBackend for WebBackend {
|
||||
self.ensure_connected(connection).await?;
|
||||
let connection_id = &connection.id;
|
||||
match command {
|
||||
MongoCommand::InDatabase { database, command } => {
|
||||
Box::pin(self.execute_mongo_command(connection, database, command)).await
|
||||
}
|
||||
MongoCommand::Version => {
|
||||
let version: String = self
|
||||
.request(
|
||||
|
||||
@@ -2202,11 +2202,13 @@ fn validate_mongo_command_with_groups(
|
||||
}
|
||||
let effective_policy = effective_policy_for_database_with_groups(policy, group_ids, connection, database);
|
||||
let permissions = mcp_permissions(connection, &effective_policy);
|
||||
// getSiblingDB() targets another database for this command only; judge that one.
|
||||
let target_database = command.target_database().unwrap_or(database);
|
||||
let production_database = match &command {
|
||||
MongoCommand::Aggregate { pipeline, .. } => {
|
||||
mongo_pipeline_targets_production_database(connection, database, pipeline)
|
||||
mongo_pipeline_targets_production_database(connection, target_database, pipeline)
|
||||
}
|
||||
_ => is_production_database(connection, database),
|
||||
_ => is_production_database(connection, target_database),
|
||||
};
|
||||
if let Err(error) =
|
||||
mongo::validate_safety(&command, permissions.allow_writes, permissions.allow_dangerous, production_database)
|
||||
|
||||
+41
-1
@@ -6,6 +6,7 @@ import {
|
||||
evaluateMongoWriteSafety,
|
||||
mongoAggregateWriteStage,
|
||||
listChainedCalls,
|
||||
MONGO_SHELL_COMMAND_HINT,
|
||||
parseMongoFindExplainCommand,
|
||||
mongoBulkWriteToQueryResult,
|
||||
mongoCollectionStatsToQueryResult,
|
||||
@@ -360,6 +361,45 @@ test("parseMongoFindCommand does not rewrite constructor text inside strings", (
|
||||
assert.deepEqual(JSON.parse(command.filter), { label: "new Date()", note: "ObjectId()" });
|
||||
});
|
||||
|
||||
test("parseMongoCommand wraps db.getSiblingDB() commands with the target database", () => {
|
||||
// The exact command from #3936.
|
||||
const parsed = parseMongoCommand(`db.getSiblingDB("iam_account").getCollection("user")
|
||||
.find({_id: NumberLong('144115205316939462')})
|
||||
.sort({phone: 1})
|
||||
.limit(21);`);
|
||||
assert.ok(parsed && parsed.command.kind === "inDatabase");
|
||||
assert.equal(parsed.command.database, "iam_account");
|
||||
assert.equal(parsed.command.command.kind, "find");
|
||||
assert.equal((parsed.command.command as { limit: number }).limit, 21);
|
||||
assert.equal((parsed.command.command as { collection: string }).collection, "user");
|
||||
|
||||
assert.equal(parseMongoCommand("db.getSiblingDB('other').orders.updateOne({a: 1}, {$set: {b: 2}})")?.command.kind, "inDatabase");
|
||||
assert.equal(parseMongoCommand('db . getSiblingDB( "x" ) . stats()')?.command.kind, "inDatabase");
|
||||
// A sibling command is one statement in a batch, and a plain one after it is unaffected.
|
||||
assert.deepEqual(
|
||||
splitMongoCommandRanges('db.getSiblingDB("a").c.find({});\ndb.d.find({})').map((range) => range.command.kind),
|
||||
["inDatabase", "find"],
|
||||
);
|
||||
// A command that parses is not a failure, even when asked directly.
|
||||
assert.equal(describeMongoCommandParseFailure('db.getSiblingDB("other").c.find({})'), MONGO_SHELL_COMMAND_HINT);
|
||||
});
|
||||
|
||||
test("parseMongoCommand rejects malformed db.getSiblingDB() with a specific reason", () => {
|
||||
for (const [source, expected] of [
|
||||
['db.getSiblingDB("x")', /requires a database name string followed by a command/],
|
||||
['db.getSiblingDB("").c.find({})', /requires a database name string/],
|
||||
["db.getSiblingDB(1).c.find({})", /requires a database name string/],
|
||||
['db.getSiblingDB("x", "y").c.find({})', /requires a database name string/],
|
||||
['db.getSiblingDB("x").getSiblingDB("y").c.find({})', /cannot be chained/],
|
||||
// Errors inside the wrapped command are reported as they would be without the prefix.
|
||||
['db.getSiblingDB("x").c.replaceOne({a: 1}, {$set: {b: 2}})', /must not contain update operators such as \$set/],
|
||||
['db.getSiblingDB("x").c.bogus()', /Collection method bogus\(\) is not supported/],
|
||||
] as const) {
|
||||
assert.equal(parseMongoCommand(source), null, source);
|
||||
assert.match(describeMongoCommandParseFailure(source), expected, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseMongoCommand accepts db["name"] bracket collection accessors', () => {
|
||||
const find = parseMongoFindCommand('db["orders-2024"].find({a: 1})');
|
||||
assert.ok(find);
|
||||
@@ -1433,7 +1473,7 @@ test("describeMongoCommandParseFailure names unsupported methods and points at a
|
||||
assert.match(describeMongoCommandParseFailure('db.getCollection("my-coll").watch()'), /watch\(\) is not supported/);
|
||||
|
||||
assert.match(describeMongoCommandParseFailure("db.getCollectionNames()"), /db\.getCollectionNames\(\) is not supported; collections are listed in the sidebar/);
|
||||
assert.match(describeMongoCommandParseFailure('db.getSiblingDB("other").c.find({})'), /use <database>/);
|
||||
assert.match(describeMongoCommandParseFailure("db.currentOp()"), /db\.currentOp\(\) is not supported/);
|
||||
assert.match(describeMongoCommandParseFailure("db.adminCommand({ping: 1})"), /use db\.runCommand/);
|
||||
assert.match(describeMongoCommandParseFailure("show collections"), /listed in the sidebar/);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user