diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index 0c9a7930fd..1e27557ecf 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -210,6 +210,11 @@ const DATABASE_METHODS = [ { label: "serverStatus", detail: "Show server status", apply: "serverStatus()" }, { label: "createUser", detail: "Create a database user", apply: 'createUser({ user: "${name}", pwd: "${password}", roles: [] })' }, { label: "createCollection", detail: "Create a collection", apply: 'createCollection("${name}")' }, + { label: "createView", detail: "Create a view over a collection", apply: 'createView("${name}", "${source}", [\n { $match: {} }\n])' }, + { label: "getUsers", detail: "List the users of this database", apply: "getUsers()" }, + { label: "getUser", detail: "Show one user of this database", apply: 'getUser("${name}")' }, + { label: "getRoles", detail: "List the roles of this database", apply: "getRoles()" }, + { label: "getRole", detail: "Show one role of this database", apply: 'getRole("${name}")' }, { label: "dropDatabase", detail: "Drop the current database", apply: "dropDatabase()" }, ] as const; @@ -288,6 +293,7 @@ const METHOD_ARG_ROLES: Record = { dropIndexes: ["indexName"], // Database-level helpers whose argument is a document. createCollection: ["name", "options"], + createView: ["name", "name", "pipeline", "options"], runCommand: ["options"], createUser: ["options"], }; @@ -1167,7 +1173,7 @@ function classifyCursorInCall(method: string, scan: MongoCallScan): MongoCursorC } return scan.stack.length === 0 ? { mode: "indexName", method } : { mode: "none" }; case "pipeline": - return classifyPipeline(scan); + return method === "createView" ? classifyPipeline(scan, findPipelineArrayIndex(scan.stack), "view") : classifyPipeline(scan); case "operations": return classifyBulkWriteOperations(scan); case "options": @@ -1744,6 +1750,8 @@ function rootItems(prefix: string): MongoCompletionItem[] { const SHOW_SUBCOMMANDS = [ { label: "dbs", detail: "List available databases", apply: "dbs", boost: 210 }, { label: "databases", detail: "List available databases", apply: "databases", boost: 200 }, + { label: "users", detail: "List the users of this database", apply: "users", boost: 190 }, + { label: "roles", detail: "List the roles of this database", apply: "roles", boost: 180 }, ] as const; function showSubcommandItems(prefix: string): MongoCompletionItem[] { @@ -2134,7 +2142,7 @@ function matchSiblingCollectionPrefix(beforeCursor: string): boolean { return !!lastCodeMatch(new RegExp(String.raw`(?:^|[\s;(])${SIBLING_ROOT_PATTERN}\s*\.([A-Za-z_][\w$-]*(?:\.[\w$-]*)*)$`), beforeCursor); } -const MONGO_COMMAND_LINE_START_PATTERN = /(?:use\b|show\s+(?:dbs|databases|collections)\b|db(?:\s*\.|\b))/iy; +const MONGO_COMMAND_LINE_START_PATTERN = /(?:use\b|show\s+(?:dbs|databases|collections|users|roles)\b|db(?:\s*\.|\b))/iy; function isMongoCommandLineStart(text: string, index: number): boolean { MONGO_COMMAND_LINE_START_PATTERN.lastIndex = index; diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 31447b5c24..698ca15698 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -107,11 +107,16 @@ const DATABASE_METHOD_SHAPES: Record = { serverStatus: { expects: "no arguments", roles: [] }, dropDatabase: { expects: "no arguments", roles: [] }, createCollection: { expects: "a collection name and optional options", roles: ["name", "options"] }, + createView: { expects: "a view name, a source collection name, a pipeline, and optional options", roles: ["name", "source", "pipeline", "options"] }, + getUsers: { expects: "optional options", roles: ["options"] }, + getUser: { expects: "a user name and optional options", roles: ["name", "options"] }, + getRoles: { expects: "optional options", roles: ["options"] }, + getRole: { expects: "a role name and optional options", roles: ["name", "options"] }, createUser: { expects: "a user document and optional write concern", roles: ["user", "writeConcern"] }, runCommand: { expects: "one command document", roles: ["command"] }, }; -const SUPPORTED_DATABASE_METHODS = ["version", "stats", "serverStatus", "createCollection", "dropDatabase", "createUser", "runCommand", "getCollection", "getSiblingDB"]; +const SUPPORTED_DATABASE_METHODS = ["version", "stats", "serverStatus", "createCollection", "createView", "dropDatabase", "createUser", "getUsers", "getUser", "getRoles", "getRole", "runCommand", "getCollection", "getSiblingDB"]; const SUPPORTED_VALUE_CONSTRUCTORS = ["ObjectId", "ISODate", "new Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"]; @@ -137,7 +142,7 @@ function diagnoseMongoCommand(source: string): string | 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."; + return "show collections is not supported here; collections are listed in the sidebar. Supported: show dbs, show users, show roles."; } const shape = COMMAND_SHAPE.exec(source); @@ -809,11 +814,83 @@ function parseMongoCreateCollectionCommand(source: string): MongoRunCommand | nu return { commandJson: JSON.stringify(command) }; } +/** `db.createView(name, source, pipeline[, options])` is the create command with viewOn and pipeline. */ +function parseMongoCreateViewCommand(source: string): MongoRunCommand | null { + const args = databaseMethodArguments(source, "createView"); + if (!args || args.length < 3 || args.length > 4) return null; + const name = parseMongoNameArgument(args[0]!); + const viewOn = parseMongoNameArgument(args[1]!); + const pipelineJson = normalizeJsonArgument(args[2]!); + if (!name || !viewOn || !pipelineJson) return null; + const pipeline = JSON.parse(pipelineJson) as unknown; + if (!Array.isArray(pipeline)) return null; + const command: Record = { create: name, viewOn, pipeline }; + const options = parseMongoOptionsArgument(args[3], ["create", "viewOn", "pipeline"]); + if (options === null) return null; + return { commandJson: JSON.stringify({ ...command, ...options }) }; +} + +/** `db.getUsers()` / `db.getUser(name)` and the role equivalents read usersInfo / rolesInfo, as mongosh does. */ +function parseMongoUserInfoCommand(source: string): MongoRunCommand | null { + const show = /^show\s+(users|roles)$/i.exec(source)?.[1]?.toLowerCase(); + if (show) return { commandJson: JSON.stringify(show === "users" ? { usersInfo: 1 } : { rolesInfo: 1 }) }; + for (const [method, command] of [ + ["getUsers", "usersInfo"], + ["getRoles", "rolesInfo"], + ] as const) { + const args = databaseMethodArguments(source, method); + if (!args) continue; + if (args.length > 1) return null; + const options = parseMongoOptionsArgument(args[0], [command]); + return options === null ? null : { commandJson: JSON.stringify({ [command]: 1, ...options }) }; + } + for (const [method, command] of [ + ["getUser", "usersInfo"], + ["getRole", "rolesInfo"], + ] as const) { + const args = databaseMethodArguments(source, method); + if (!args) continue; + if (args.length < 1 || args.length > 2) return null; + const name = parseMongoNameArgument(args[0]!); + const options = parseMongoOptionsArgument(args[1], [command]); + return name && options !== null ? { commandJson: JSON.stringify({ [command]: name, ...options }) } : null; + } + return null; +} + +/** Arguments of `db.(...)` when the whole source is that one call, otherwise null. */ +function databaseMethodArguments(source: string, method: string): string[] | null { + const match = new RegExp(`^db\\s*\\.\\s*${method}\\s*\\(`, "i").exec(source); + if (!match) return null; + const openIndex = source.indexOf("(", match.index); + const closeIndex = findMatchingParen(source, openIndex); + if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null; + const args = splitTopLevel(source.slice(openIndex + 1, closeIndex)); + return args.length === 1 && !args[0]?.trim() ? [] : args; +} + +function parseMongoNameArgument(argument: string): string | null { + return /^(["'])([^"'$]+)\1$/.exec(argument.trim())?.[2]?.trim() || null; +} + +/** An optional options document, rejected when it would override one of the command's own keys. */ +function parseMongoOptionsArgument(argument: string | undefined, reservedKeys: readonly string[]): Record | null { + if (!argument?.trim()) return {}; + const optionsJson = parseMongoObjectArgument(argument); + if (!optionsJson) return null; + const options = JSON.parse(optionsJson) as Record; + return reservedKeys.some((key) => key in options) ? null : options; +} + export function parseMongoRunCommand(input: string): MongoRunCommand | null { const source = input.trim().replace(/;$/, "").trim(); if (/^db\s*\.\s*dropDatabase\s*\(\s*\)$/i.test(source)) return { commandJson: JSON.stringify({ dropDatabase: 1 }) }; const createCollection = parseMongoCreateCollectionCommand(source); if (createCollection) return createCollection; + const createView = parseMongoCreateViewCommand(source); + if (createView) return createView; + const userInfo = parseMongoUserInfoCommand(source); + if (userInfo) return userInfo; for (const [method, command] of Object.entries(DATABASE_STATUS_COMMANDS)) { if (new RegExp(`^db\\s*\\.\\s*${method}\\s*\\(\\s*\\)$`, "i").test(source)) { return { commandJson: JSON.stringify({ [command]: 1 }) }; @@ -1580,7 +1657,7 @@ function mongoTopLevelCommandLineStarts(segment: string): number[] { function isMongoCommandLineStart(segment: string, index: number): boolean { const rest = segment.slice(index); - return /^use\b/i.test(rest) || /^show\s+(?:dbs|databases)\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest); + return /^use\b/i.test(rest) || /^show\s+(?:dbs|databases|users|roles)\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest); } function pushMongoSegment(segments: MongoTextRange[], source: string, from: number, to: number) { diff --git a/crates/dbx-driver-mongodb/src/mongo_shell.rs b/crates/dbx-driver-mongodb/src/mongo_shell.rs index 48ab0a043c..1f950bf0bc 100644 --- a/crates/dbx-driver-mongodb/src/mongo_shell.rs +++ b/crates/dbx-driver-mongodb/src/mongo_shell.rs @@ -551,6 +551,9 @@ pub fn parse(input: &str) -> Result { if parse_show_databases(show_source) { return Ok(MongoCommand::ShowDatabases); } + if let Some(command) = parse_show_users_or_roles(show_source) { + return Ok(MongoCommand::RunCommand { command_json: format!(r#"{{"{command}":1}}"#) }); + } let source = input.trim().trim_end_matches(';').trim(); if source.eq_ignore_ascii_case("db.version()") { return Ok(MongoCommand::Version); @@ -611,6 +614,57 @@ pub fn parse(input: &str) -> Result { } return Ok(MongoCommand::RunCommand { command_json: Value::Object(command).to_string() }); } + // `db.createView(name, source, pipeline[, options])` is the create command with viewOn and pipeline. + if let Some((args, tail)) = database_method_call(source, "createView") { + if !tail.is_empty() || !(3..=4).contains(&args.len()) { + return Err( + "MongoDB createView() requires a view name, a source collection name, a pipeline, and optional options." + .to_string(), + ); + } + let name = parse_string_arg(&args[0])?; + let view_on = parse_string_arg(&args[1])?; + if [&name, &view_on].iter().any(|name| name.trim().is_empty() || name.contains('$')) { + return Err("MongoDB createView() requires valid view and source collection names.".to_string()); + } + let Some(pipeline @ Value::Array(_)) = parse_json_value(&normalized_json(&args[2])?) else { + return Err("MongoDB createView() pipeline must be an array of stages.".to_string()); + }; + let mut command = serde_json::Map::new(); + command.insert("create".to_string(), Value::String(name)); + command.insert("viewOn".to_string(), Value::String(view_on)); + command.insert("pipeline".to_string(), pipeline); + command.extend(parse_database_method_options("createView", args.get(3), &["create", "viewOn", "pipeline"])?); + return Ok(MongoCommand::RunCommand { command_json: Value::Object(command).to_string() }); + } + // `db.getUsers()` / `db.getUser(name)` and the role equivalents read usersInfo / rolesInfo, as mongosh does. + for (method, command) in [("getUsers", "usersInfo"), ("getRoles", "rolesInfo")] { + if let Some((args, tail)) = database_method_call(source, method) { + let args: Vec<_> = args.into_iter().filter(|arg| !arg.trim().is_empty()).collect(); + if !tail.is_empty() || args.len() > 1 { + return Err(format!("MongoDB {method}() accepts only an optional options document.")); + } + let mut command_doc = serde_json::Map::new(); + command_doc.insert(command.to_string(), Value::from(1)); + command_doc.extend(parse_database_method_options(method, args.first(), &[command])?); + return Ok(MongoCommand::RunCommand { command_json: Value::Object(command_doc).to_string() }); + } + } + for (method, command) in [("getUser", "usersInfo"), ("getRole", "rolesInfo")] { + if let Some((args, tail)) = database_method_call(source, method) { + if !tail.is_empty() || !(1..=2).contains(&args.len()) || args[0].trim().is_empty() { + return Err(format!("MongoDB {method}() requires a name and optional options.")); + } + let name = parse_string_arg(&args[0])?; + if name.trim().is_empty() { + return Err(format!("MongoDB {method}() requires a non-empty name.")); + } + let mut command_doc = serde_json::Map::new(); + command_doc.insert(command.to_string(), Value::String(name)); + command_doc.extend(parse_database_method_options(method, args.get(1), &[command])?); + return Ok(MongoCommand::RunCommand { command_json: Value::Object(command_doc).to_string() }); + } + } for (method, command) in [("stats", "dbStats"), ("serverStatus", "serverStatus")] { if let Some((args, tail)) = database_method_call(source, method) { if !tail.is_empty() || !args.iter().all(|arg| arg.trim().is_empty()) { @@ -979,6 +1033,38 @@ fn parse_show_databases(source: &str) -> bool { && words.next().is_none() } +/// `show users` / `show roles` name the usersInfo / rolesInfo command for the current database. +fn parse_show_users_or_roles(source: &str) -> Option<&'static str> { + let mut words = source.split_whitespace(); + if !words.next().is_some_and(|word| word.eq_ignore_ascii_case("show")) { + return None; + } + let command = match words.next()? { + word if word.eq_ignore_ascii_case("users") => "usersInfo", + word if word.eq_ignore_ascii_case("roles") => "rolesInfo", + _ => return None, + }; + words.next().is_none().then_some(command) +} + +/// An optional options document for a database helper, rejected when it would override one of the command's own keys. +fn parse_database_method_options( + method: &str, + arg: Option<&String>, + reserved_keys: &[&str], +) -> Result, String> { + let Some(arg) = arg.filter(|arg| !arg.trim().is_empty()) else { + return Ok(serde_json::Map::new()); + }; + let Some(Value::Object(options)) = parse_json_value(&normalized_json(arg)?) else { + return Err(format!("MongoDB {method}() options must be a document.")); + }; + if let Some(key) = reserved_keys.iter().find(|key| options.contains_key(**key)) { + return Err(format!("MongoDB {method}() options must not contain {key}.")); + } + Ok(options) +} + fn trim_mongo_outer_comments(mut source: &str) -> &str { loop { source = source.trim_start(); @@ -2249,6 +2335,72 @@ mod tests { assert!(parse("db.orders.estimatedDocumentCount().limit(5)").unwrap_err().contains("chained")); } + #[test] + fn parses_create_view_as_a_create_command() { + let command = parse(r#"db.createView("active_users", "users", [{ $match: { active: true } }])"#).unwrap(); + let MongoCommand::RunCommand { command_json } = command else { panic!("expected runCommand") }; + assert_eq!( + serde_json::from_str::(&command_json).unwrap(), + serde_json::json!({ "create": "active_users", "viewOn": "users", "pipeline": [{ "$match": { "active": true } }] }) + ); + let MongoCommand::RunCommand { command_json } = + parse("db.createView('v', 'users', [], { collation: { locale: 'fr' } });").unwrap() + else { + panic!("expected runCommand") + }; + assert_eq!( + serde_json::from_str::(&command_json).unwrap(), + serde_json::json!({ "create": "v", "viewOn": "users", "pipeline": [], "collation": { "locale": "fr" } }) + ); + + for (source, expected) in [ + (r#"db.createView("v", "users")"#, "requires a view name"), + (r#"db.createView("v", "users", {})"#, "pipeline must be an array"), + (r#"db.createView("v", "a$b", [])"#, "valid view and source collection names"), + (r#"db.createView("v", "users", [], { viewOn: "x" })"#, "must not contain viewOn"), + (r#"db.createView("v", "users", []).x()"#, "requires a view name"), + ] { + assert!(parse(source).unwrap_err().contains(expected), "{source}"); + } + } + + #[test] + fn parses_user_and_role_helpers_as_info_commands() { + for (source, expected) in [ + ("show users", serde_json::json!({ "usersInfo": 1 })), + ("SHOW ROLES;", serde_json::json!({ "rolesInfo": 1 })), + ("db.getUsers()", serde_json::json!({ "usersInfo": 1 })), + ( + "db.getUsers({ showCredentials: false })", + serde_json::json!({ "usersInfo": 1, "showCredentials": false }), + ), + (r#"db.getUser("app")"#, serde_json::json!({ "usersInfo": "app" })), + ( + "db.getUser('app', { showPrivileges: true })", + serde_json::json!({ "usersInfo": "app", "showPrivileges": true }), + ), + ( + "db.getRoles({ showBuiltinRoles: true })", + serde_json::json!({ "rolesInfo": 1, "showBuiltinRoles": true }), + ), + (r#"db.getRole("reader")"#, serde_json::json!({ "rolesInfo": "reader" })), + ] { + let MongoCommand::RunCommand { command_json } = parse(source).unwrap() else { panic!("{source}") }; + assert_eq!(serde_json::from_str::(&command_json).unwrap(), expected, "{source}"); + } + + for (source, expected) in [ + ("db.getUser()", "requires a name"), + ("db.getUser(1)", "must be a string"), + ("db.getUsers({ usersInfo: 2 })", "must not contain usersInfo"), + ("db.getRoles({}, {})", "optional options document"), + ] { + assert!(parse(source).unwrap_err().contains(expected), "{source}"); + } + assert!(parse("show users extra").is_err()); + assert!(parse("show collections").is_err()); + } + #[test] fn parses_db_stats_and_server_status_as_run_commands() { assert_eq!( diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index 43ff1acf46..118c8792fd 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -343,6 +343,7 @@ test("offers the newly supported count and database commands", () => { assert.ok(dbLevel.includes("serverStatus")); assert.ok(dbLevel.includes("createCollection")); assert.ok(dbLevel.includes("dropDatabase")); + for (const method of ["createView", "getUsers", "getUser", "getRoles", "getRole"]) assert.ok(dbLevel.includes(method), method); assert.ok(labels("").includes("db.stats")); }); @@ -793,6 +794,9 @@ test("withholds illegal stages in sub-pipelines and view definitions", () => { // View definition pipeline rejects $out and $merge, but permits $documents. const viewStages = labels('db.createCollection("v", { viewOn: "users", pipeline: [{ $'); + const createViewStages = labels('db.createView("v", "users", [{ $'); + assert.ok(createViewStages.includes("$match")); + assert.equal(createViewStages.includes("$out"), false, "views cannot write"); assert.equal(viewStages.includes("$out"), false, "view pipeline must not offer $out"); assert.equal(viewStages.includes("$merge"), false, "view pipeline must not offer $merge"); assert.ok(viewStages.includes("$match")); @@ -1701,9 +1705,9 @@ test("suggests show dbs and show subcommands", () => { assert.ok(buildMongoCompletionItems("show", 4).some((item) => item.label === "show dbs")); assert.ok(parseMongoCommand("show dbs"), "show dbs snippet must parse"); - // show followed by space offers dbs and databases, and NOT db.* snippets + // show followed by space offers its subcommands, and NOT db.* snippets const showSpaceItems = labels("show "); - assert.deepEqual(showSpaceItems, ["dbs", "databases"]); + assert.deepEqual(showSpaceItems, ["dbs", "databases", "users", "roles"]); assert.equal(showSpaceItems.includes("db.collection.find"), false); assert.equal(showSpaceItems.includes("db.version"), false); assert.equal(showSpaceItems.includes("collections"), false, "show collections is not supported"); @@ -1712,6 +1716,9 @@ test("suggests show dbs and show subcommands", () => { assert.deepEqual(labels("show d"), ["dbs", "databases"]); assert.deepEqual(labels("show db"), ["dbs"]); assert.deepEqual(labels("show dat"), ["databases"]); + assert.deepEqual(labels("show us"), ["users"]); + assert.deepEqual(labels("show ro"), ["roles"]); + for (const source of ["show users", "show roles"]) assert.ok(parseMongoCommand(source), `${source} must parse`); assert.deepEqual(labels("show col"), []); // Context and auto-trigger diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 937ce2ebe6..08bfa76a2c 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -1474,6 +1474,64 @@ test("parseMongoRunCommand reads db.dropDatabase() and db.createCollection() as } }); +test("parseMongoRunCommand reads db.createView() as a create command with viewOn and pipeline", () => { + assert.deepEqual(JSON.parse(parseMongoRunCommand('db.createView("active_users", "users", [{ $match: { active: true } }])')!.commandJson), { + create: "active_users", + viewOn: "users", + pipeline: [{ $match: { active: true } }], + }); + assert.deepEqual(JSON.parse(parseMongoRunCommand("db.createView('v', 'users', [], { collation: { locale: 'fr' } });")!.commandJson), { + create: "v", + viewOn: "users", + pipeline: [], + collation: { locale: "fr" }, + }); + assert.equal(parseMongoCommand('db.createView("v", "users", [])')?.command.kind, "runCommand"); + + for (const [source, expected] of [ + ['db.createView("v", "users")', /createView\(\) expects a view name, a source collection name, a pipeline/], + ['db.createView("v", "users", {})', /createView\(\) expects/], + ['db.createView("v", 1, [])', /createView\(\) expects/], + ['db.createView("v", "users", [], { viewOn: "other" })', /createView\(\) expects/], + ['db.createView("v", "users", [{ $match: { a: Foo() } }])', /Unsupported value Foo\(\.\.\.\) in the pipeline argument of createView\(\)/], + ] as const) { + assert.equal(parseMongoCommand(source), null, source); + assert.match(describeMongoCommandParseFailure(source), expected, source); + } +}); + +test("parseMongoRunCommand reads user and role helpers as usersInfo and rolesInfo", () => { + for (const [source, expected] of [ + ["show users", { usersInfo: 1 }], + ["SHOW ROLES;", { rolesInfo: 1 }], + ["db.getUsers()", { usersInfo: 1 }], + ["db.getUsers({ showCredentials: false, filter: { roles: { $size: 0 } } })", { usersInfo: 1, showCredentials: false, filter: { roles: { $size: 0 } } }], + ['db.getUser("app")', { usersInfo: "app" }], + ["db.getUser('app', { showPrivileges: true })", { usersInfo: "app", showPrivileges: true }], + ["db.getRoles({ showBuiltinRoles: true })", { rolesInfo: 1, showBuiltinRoles: true }], + ['db.getRole("reader")', { rolesInfo: "reader" }], + ] as const) { + assert.deepEqual(JSON.parse(parseMongoRunCommand(source)?.commandJson ?? "null"), expected, source); + assert.equal(parseMongoCommand(source)?.command.kind, "runCommand", source); + } + + for (const [source, expected] of [ + ["db.getUser()", /getUser\(\) expects a user name/], + ["db.getUser(1)", /getUser\(\) expects a user name/], + ["db.getUsers({ usersInfo: 2 })", /getUsers\(\) expects optional options/], + ["db.getRoles({}, {})", /getRoles\(\) expects optional options/], + ['db.getRole("a").x()', /Unexpected text after getRole/], + ] as const) { + assert.equal(parseMongoCommand(source), null, source); + assert.match(describeMongoCommandParseFailure(source), expected, source); + } + assert.equal(parseMongoCommand("show users extra"), null); + assert.deepEqual( + splitMongoCommands("use admin\nshow users\nshow roles").map(({ command }) => command.kind), + ["use", "runCommand", "runCommand"], + ); +}); + test("parseMongoRunCommand reads db.stats() and db.serverStatus() as run commands", () => { assert.deepEqual(parseMongoRunCommand("db.stats()"), { commandJson: '{"dbStats":1}' }); assert.deepEqual(parseMongoRunCommand("db . serverStatus ( ) ;"), { commandJson: '{"serverStatus":1}' });