From f1e0f54a2ba0e755afebd19e490fb31e96200dfa Mon Sep 17 00:00:00 2001 From: Loc Nguyen <113083148+thailoc-dev@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:38:53 +0700 Subject: [PATCH] feat(mongodb): support db.getSiblingDB() --- apps/desktop/src/lib/mongo/mongoCompletion.ts | 1 + .../src/lib/mongo/mongoShellCommand.ts | 41 ++++++- .../queryStore.mongoExecutionSummary.spec.ts | 21 ++++ apps/desktop/src/stores/queryStore.ts | 38 +++++- crates/dbx-core/src/query/mongo_ops.rs | 3 + crates/dbx-drivers/src/mongo_shell.rs | 115 ++++++++++++++++++ crates/dbx-mcp/src/backend.rs | 3 + crates/dbx-mcp/src/server.rs | 6 +- packages/app-tests/mongoShellCommand.test.ts | 42 ++++++- 9 files changed, 262 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index 48addea356..1863b21fcb 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -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}")' }, diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 1deffb502d..fef95f262c 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -97,7 +97,6 @@ const SUPPORTED_COLLECTION_METHODS = [ /** Database-level methods with a supported equivalent worth pointing at. */ const DATABASE_METHOD_HINTS: Record = { - getSiblingDB: "switch databases with `use ` and then run the command against db.", adminCommand: "use db.runCommand({ ... })", getCollectionNames: "collections are listed in the sidebar", }; @@ -112,7 +111,7 @@ const DATABASE_METHOD_SHAPES: Record = { 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").` 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; diff --git a/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts index 81069aef17..c309a81c5c 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts @@ -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( diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 1d927c31a4..4b558f3e48 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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").: 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..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..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); diff --git a/crates/dbx-core/src/query/mongo_ops.rs b/crates/dbx-core/src/query/mongo_ops.rs index f3de681da5..fb34c12587 100644 --- a/crates/dbx-core/src/query/mongo_ops.rs +++ b/crates/dbx-core/src/query/mongo_ops.rs @@ -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) diff --git a/crates/dbx-drivers/src/mongo_shell.rs b/crates/dbx-drivers/src/mongo_shell.rs index b4c8c6bf84..d742c88ae6 100644 --- a/crates/dbx-drivers/src/mongo_shell.rs +++ b/crates/dbx-drivers/src/mongo_shell.rs @@ -90,6 +90,10 @@ pub enum MongoCommand { FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option }, #[serde(rename = "findOneAndDelete")] FindOneAndDelete { collection: String, filter: String, options: Option }, + /// `db.getSiblingDB("name").`: 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 }, } #[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 { 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, 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 { 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 [ diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index fa78fecf0d..707f516459 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -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( diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index 960d14a46f..637d4b8e76 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -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) diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index aaac83bbb9..b87847b387 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -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 /); + 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/);