feat(mongodb): support createview and user role shell helpers

This commit is contained in:
Loc Nguyen
2026-10-15 12:52:19 +08:00
committed by GitHub
parent 3af7e2c55d
commit b7553e6489
5 changed files with 309 additions and 7 deletions
+10 -2
View File
@@ -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<string, readonly MongoArgRole[]> = {
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;
@@ -107,11 +107,16 @@ const DATABASE_METHOD_SHAPES: Record<string, MongoMethodShape> = {
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<string, unknown> = { 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.<method>(...)` 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<string, unknown> | null {
if (!argument?.trim()) return {};
const optionsJson = parseMongoObjectArgument(argument);
if (!optionsJson) return null;
const options = JSON.parse(optionsJson) as Record<string, unknown>;
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) {
@@ -551,6 +551,9 @@ pub fn parse(input: &str) -> Result<MongoCommand, String> {
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<MongoCommand, String> {
}
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<serde_json::Map<String, Value>, 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::<Value>(&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::<Value>(&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::<Value>(&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!(
+9 -2
View File
@@ -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
+58
View File
@@ -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}' });