fix(mongodb): accept mongosh number type constructors

This commit is contained in:
Loc Nguyen
2026-10-16 12:01:32 +08:00
committed by GitHub
parent fd79509106
commit c2b7e427af
6 changed files with 101 additions and 16 deletions
@@ -1006,6 +1006,10 @@ export const VALUE_SNIPPETS: MongoOperatorSpec[] = specs([
["NumberLong", "64-bit integer value", 'NumberLong("${value}")'], ["NumberLong", "64-bit integer value", 'NumberLong("${value}")'],
["NumberInt", "32-bit integer value", "NumberInt(${value})"], ["NumberInt", "32-bit integer value", "NumberInt(${value})"],
["NumberDecimal", "128-bit decimal value", 'NumberDecimal("${value}")'], ["NumberDecimal", "128-bit decimal value", 'NumberDecimal("${value}")'],
["Int32", "32-bit integer value", "Int32(${value})"],
["Long", "64-bit integer value", 'Long("${value}")'],
["Double", "64-bit floating point value", "Double(${value})"],
["Decimal128", "128-bit decimal value", 'Decimal128("${value}")'],
["UUID", "UUID value", 'UUID("${uuid}")'], ["UUID", "UUID value", 'UUID("${uuid}")'],
["BinData", "Binary value", 'BinData(0, "${base64}")'], ["BinData", "Binary value", 'BinData(0, "${base64}")'],
["Timestamp", "Internal timestamp value", "Timestamp(${seconds}, ${ordinal})"], ["Timestamp", "Internal timestamp value", "Timestamp(${seconds}, ${ordinal})"],
@@ -118,7 +118,7 @@ const DATABASE_METHOD_SHAPES: Record<string, MongoMethodShape> = {
const SUPPORTED_DATABASE_METHODS = ["version", "stats", "serverStatus", "createCollection", "createView", "dropDatabase", "createUser", "getUsers", "getUser", "getRoles", "getRole", "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"]; const SUPPORTED_VALUE_CONSTRUCTORS = ["ObjectId", "ISODate", "new Date", "NumberLong", "NumberInt", "NumberDecimal", "Int32", "Long", "Double", "Decimal128", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"];
const COMMAND_SHAPE = /^db\s*(?:\.\s*(?<collection>[A-Za-z_$][\w$]*)|\[\s*(["'])(?<bracket>.*?)\2\s*\]|\.\s*getCollection\s*\(\s*(["'])(?<named>.*?)\4\s*\))?\s*\.\s*(?<method>[A-Za-z_$][\w$]*)\s*\(/; const COMMAND_SHAPE = /^db\s*(?:\.\s*(?<collection>[A-Za-z_$][\w$]*)|\[\s*(["'])(?<bracket>.*?)\2\s*\]|\.\s*getCollection\s*\(\s*(["'])(?<named>.*?)\4\s*\))?\s*\.\s*(?<method>[A-Za-z_$][\w$]*)\s*\(/;
@@ -210,7 +210,7 @@ function diagnoseMongoCommand(source: string): string | null {
/** First `Name(` outside a string that is not a constructor the parser understands. */ /** First `Name(` outside a string that is not a constructor the parser understands. */
function findUnsupportedValueConstructor(argument: string): string | null { function findUnsupportedValueConstructor(argument: string): string | null {
const known = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey", "deserialize"]); const known = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", "Int32", "Long", "Double", "Decimal128", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey", "deserialize"]);
let quote: string | null = null; let quote: string | null = null;
let escaped = false; let escaped = false;
for (let index = 0; index < argument.length; index += 1) { for (let index = 0; index < argument.length; index += 1) {
+52 -6
View File
@@ -1477,13 +1477,18 @@ fn is_shell_regex_value_prefix(previous_significant: Option<char>) -> bool {
/// Shell value constructors rewritten to extended JSON before json5 parsing. /// Shell value constructors rewritten to extended JSON before json5 parsing.
/// `Date` is only recognised after `new`, matching the shell where a bare `Date()` /// `Date` is only recognised after `new`, matching the shell where a bare `Date()`
/// returns a string rather than a date. /// returns a string rather than a date.
const SHELL_CONSTRUCTORS: [&str; 11] = [ const SHELL_CONSTRUCTORS: [&str; 15] = [
"ObjectId", "ObjectId",
"ISODate", "ISODate",
"Date", "Date",
"NumberLong", "NumberLong",
"NumberInt", "NumberInt",
"NumberDecimal", "NumberDecimal",
// mongosh's BSON type names for the same values.
"Int32",
"Long",
"Double",
"Decimal128",
"UUID", "UUID",
"BinData", "BinData",
"Timestamp", "Timestamp",
@@ -1621,6 +1626,10 @@ fn key_constant_json(name: &str) -> String {
/// Rewrite one `Name(...)` / `new Name(...)` call at `index`, or None when it is not a known constructor. /// Rewrite one `Name(...)` / `new Name(...)` call at `index`, or None when it is not a known constructor.
fn shell_constructor_call(input: &str, index: usize) -> Result<Option<(String, usize)>, String> { fn shell_constructor_call(input: &str, index: usize) -> Result<Option<(String, usize)>, String> {
// Only at the start of a name, so `myLong(1)` is not read as `Long(1)`.
if input[..index].chars().next_back().is_some_and(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '$' | '.')) {
return Ok(None);
}
let rest = &input[index..]; let rest = &input[index..];
let (name_start, is_new) = match rest.strip_prefix("new") { let (name_start, is_new) = match rest.strip_prefix("new") {
Some(after) if after.starts_with(|ch: char| ch.is_whitespace()) => { Some(after) if after.starts_with(|ch: char| ch.is_whitespace()) => {
@@ -1670,8 +1679,8 @@ fn shell_constructor_to_extended_json(name: &str, inner: &str) -> Result<String,
Some(millis) => Ok(format!(r#"{{"$date":{{"$numberLong":"{millis}"}}}}"#)), Some(millis) => Ok(format!(r#"{{"$date":{{"$numberLong":"{millis}"}}}}"#)),
None => Ok(extended_json("$date", &parse_string_arg(inner)?)), None => Ok(extended_json("$date", &parse_string_arg(inner)?)),
}, },
"NumberLong" | "NumberInt" => { "NumberLong" | "NumberInt" | "Long" | "Int32" => {
let key = if name == "NumberLong" { "$numberLong" } else { "$numberInt" }; let key = if matches!(name, "NumberLong" | "Long") { "$numberLong" } else { "$numberInt" };
let value = match integer { let value = match integer {
Some(value) => value.to_string(), Some(value) => value.to_string(),
None => parse_string_arg(inner)?, None => parse_string_arg(inner)?,
@@ -1679,13 +1688,13 @@ fn shell_constructor_to_extended_json(name: &str, inner: &str) -> Result<String,
value.parse::<i64>().map_err(|_| format!("MongoDB {name}() requires an integer argument."))?; value.parse::<i64>().map_err(|_| format!("MongoDB {name}() requires an integer argument."))?;
Ok(extended_json(key, &value)) Ok(extended_json(key, &value))
} }
"NumberDecimal" => { "NumberDecimal" | "Decimal128" | "Double" => {
let value = match parse_json_value(inner) { let value = match parse_json_value(inner) {
Some(Value::Number(number)) => number.to_string(), Some(Value::Number(number)) => number.to_string(),
_ => parse_string_arg(inner)?, _ => parse_string_arg(inner)?,
}; };
value.parse::<f64>().map_err(|_| "MongoDB NumberDecimal() requires a numeric argument.".to_string())?; value.parse::<f64>().map_err(|_| format!("MongoDB {name}() requires a numeric argument."))?;
Ok(extended_json("$numberDecimal", &value)) Ok(extended_json(if name == "Double" { "$numberDouble" } else { "$numberDecimal" }, &value))
} }
_ => Err(format!("Unsupported MongoDB value constructor {name}().")), _ => Err(format!("Unsupported MongoDB value constructor {name}().")),
} }
@@ -2177,6 +2186,43 @@ mod tests {
assert!(oid.len() == 24 && oid.chars().all(|ch| ch.is_ascii_hexdigit()), "{oid}"); assert!(oid.len() == 24 && oid.chars().all(|ch| ch.is_ascii_hexdigit()), "{oid}");
} }
#[test]
fn rewrites_mongosh_bson_type_constructors() {
let MongoCommand::Insert { documents, .. } = parse(
"db.getCollection(\"issue_demo\").insert({\n name: \"demo\",\n coins: Int32(\"15000\"),\n amount: Decimal128(\"100\")\n});",
)
.unwrap() else {
panic!("expected an insert command");
};
assert_eq!(
parse_json_value(&documents).unwrap(),
serde_json::json!({ "name": "demo", "coins": { "$numberInt": "15000" }, "amount": { "$numberDecimal": "100" } })
);
let MongoCommand::Find { filter, .. } = parse(
r#"db.orders.find({a: Int32(3), b: new Int32(-4), c: Long("9223372036854775807"), d: Long(5), e: Double(1.5), f: Double("2"), g: Decimal128("12.34")})"#,
)
.unwrap() else {
panic!("expected a find command");
};
assert_eq!(
parse_json_value(&filter).unwrap(),
serde_json::json!({
"a": { "$numberInt": "3" },
"b": { "$numberInt": "-4" },
"c": { "$numberLong": "9223372036854775807" },
"d": { "$numberLong": "5" },
"e": { "$numberDouble": "1.5" },
"f": { "$numberDouble": "2" },
"g": { "$numberDecimal": "12.34" },
})
);
assert!(parse(r#"db.orders.find({a: Long("x")})"#).unwrap_err().contains("Long() requires an integer"));
assert!(parse(r#"db.orders.find({a: Double("abc")})"#).unwrap_err().contains("Double() requires a numeric"));
assert!(parse("db.orders.find({a: myLong(1)})").is_err());
}
#[test] #[test]
fn rewrites_epoch_millisecond_and_numeric_value_constructors() { fn rewrites_epoch_millisecond_and_numeric_value_constructors() {
let MongoCommand::Find { filter, .. } = let MongoCommand::Find { filter, .. } =
+1 -1
View File
@@ -2132,7 +2132,7 @@ test("replaces typed new keyword when completing new Date", () => {
// keeps the other `…d…` constructors alongside `new Date`. // keeps the other `…d…` constructors alongside `new Date`.
assert.deepEqual( assert.deepEqual(
items1.map((candidate) => candidate.label), items1.map((candidate) => candidate.label),
["ISODate", "ObjectId", "BinData", "new Date", "NumberDecimal", "UUID"], ["Decimal128", "Double", "ISODate", "ObjectId", "BinData", "new Date", "NumberDecimal", "UUID"],
); );
// Bad case 2: cursor after `new ` (trailing space) // Bad case 2: cursor after `new ` (trailing space)
+28
View File
@@ -363,6 +363,34 @@ test("parseMongoFindCommand rewrites epoch-millisecond and numeric BSON construc
}); });
}); });
test("parseMongoCommand accepts the mongosh Int32, Long, Double and Decimal128 constructors", () => {
const insert = parseMongoCommand('db.getCollection("issue_demo").insert({\n name: "demo",\n coins: Int32("15000"),\n amount: Decimal128("100")\n});');
assert.equal(insert?.command.kind, "insert");
assert.deepEqual(JSON.parse((insert!.command as { docsJson: string }).docsJson), {
name: "demo",
coins: { $numberInt: "15000" },
amount: { $numberDecimal: "100" },
});
const command = parseMongoFindCommand('db.orders.find({a: Int32(3), b: new Int32(-4), c: Long("9223372036854775807"), d: Long(5), e: Double(1.5), f: Double("2"), g: Decimal128("12.34")})');
assert.ok(command);
assert.deepEqual(JSON.parse(command.filter), {
a: { $numberInt: "3" },
b: { $numberInt: "-4" },
c: { $numberLong: "9223372036854775807" },
d: { $numberLong: "5" },
e: { $numberDouble: "1.5" },
f: { $numberDouble: "2" },
g: { $numberDecimal: "12.34" },
});
for (const source of ["db.orders.find({a: Int32(2147483648)})", 'db.orders.find({a: Long("x")})', 'db.orders.find({a: Double("abc")})', "db.orders.find({a: Decimal128()})", "db.orders.find({a: myLong(1)})"]) {
assert.equal(parseMongoFindCommand(source), null, source);
}
assert.match(describeMongoCommandParseFailure("db.orders.find({a: myLong(1)})"), /Unsupported value myLong\(\.\.\.\)/);
assert.match(describeMongoCommandParseFailure("db.orders.find({a: Foo(1)})"), /Int32, Long, Double, Decimal128/);
});
test("parseMongoFindCommand rejects out-of-range integer constructor arguments", () => { test("parseMongoFindCommand rejects out-of-range integer constructor arguments", () => {
assert.equal(parseMongoFindCommand("db.orders.find({a: NumberLong(9223372036854775808)})"), null); assert.equal(parseMongoFindCommand("db.orders.find({a: NumberLong(9223372036854775808)})"), null);
assert.equal(parseMongoFindCommand("db.orders.find({a: NumberLong(-9223372036854775809)})"), null); assert.equal(parseMongoFindCommand("db.orders.find({a: NumberLong(-9223372036854775809)})"), null);
+14 -7
View File
@@ -14,7 +14,8 @@ export function normalizeJsonArgument(value: string): string | null {
const withoutEjsonDeserialize = replaceMongoEjsonDeserialize(withoutComments); const withoutEjsonDeserialize = replaceMongoEjsonDeserialize(withoutComments);
// Rewrite mongo shell constructors that are not valid JSON into extended JSON // Rewrite mongo shell constructors that are not valid JSON into extended JSON
// (mongo_driver::json_value_to_bson): ObjectId / ISODate / new Date / NumberLong / // (mongo_driver::json_value_to_bson): ObjectId / ISODate / new Date / NumberLong /
// NumberInt / NumberDecimal / UUID / BinData / Timestamp / MinKey / MaxKey. // NumberInt / NumberDecimal / UUID / BinData / Timestamp / MinKey / MaxKey, plus the
// mongosh names Int32 / Long / Double / Decimal128.
const withExtendedJson = replaceMongoShellConstructors(withoutEjsonDeserialize); const withExtendedJson = replaceMongoShellConstructors(withoutEjsonDeserialize);
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson)); const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson));
try { try {
@@ -546,7 +547,9 @@ function replaceMongoEjsonDeserialize(source: string): string {
* `Date` is only recognised after `new`, matching the shell where a bare `Date()` * `Date` is only recognised after `new`, matching the shell where a bare `Date()`
* returns a string rather than a date. * returns a string rather than a date.
*/ */
const SHELL_CONSTRUCTORS = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"]); const SHELL_CONSTRUCTORS = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", "Int32", "Long", "Double", "Decimal128", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"]);
/** mongosh's BSON type names for the legacy number constructors. */
const NUMBER_CONSTRUCTOR_ALIASES: Record<string, string> = { Int32: "NumberInt", Long: "NumberLong", Decimal128: "NumberDecimal" };
/** `MinKey` / `MaxKey` are also valid without parentheses, as in `{ $lt: MaxKey }`. */ /** `MinKey` / `MaxKey` are also valid without parentheses, as in `{ $lt: MaxKey }`. */
const BARE_KEY_CONSTANT = /^(MinKey|MaxKey)(?![\w$])/; const BARE_KEY_CONSTANT = /^(MinKey|MaxKey)(?![\w$])/;
const CONSTRUCTOR_CALL = /^(new\s+)?([A-Za-z_$][\w$]*)\s*\(/; const CONSTRUCTOR_CALL = /^(new\s+)?([A-Za-z_$][\w$]*)\s*\(/;
@@ -614,6 +617,8 @@ function keyConstantJson(name: string): string {
/** Rewrite one `Name(...)` / `new Name(...)` call at {@link index}, or null when it is not a known constructor. */ /** Rewrite one `Name(...)` / `new Name(...)` call at {@link index}, or null when it is not a known constructor. */
function matchShellConstructorCall(source: string, index: number): { json: string; end: number } | null { function matchShellConstructorCall(source: string, index: number): { json: string; end: number } | null {
// Only at the start of a name, so `myLong(1)` is not read as `Long(1)`.
if (index > 0 && /[\w$.]/.test(source[index - 1]!)) return null;
const match = CONSTRUCTOR_CALL.exec(source.slice(index)); const match = CONSTRUCTOR_CALL.exec(source.slice(index));
if (!match) return null; if (!match) return null;
const name = match[2]!; const name = match[2]!;
@@ -637,7 +642,7 @@ function shellConstructorToExtendedJson(name: string, args: string[]): string |
const arg = args[0]?.trim(); const arg = args[0]?.trim();
const literal = arg ? (QUOTED_ARGUMENT.exec(arg)?.[2] ?? null) : null; const literal = arg ? (QUOTED_ARGUMENT.exec(arg)?.[2] ?? null) : null;
switch (name) { switch (NUMBER_CONSTRUCTOR_ALIASES[name] ?? name) {
case "MinKey": case "MinKey":
case "MaxKey": case "MaxKey":
return arg ? null : keyConstantJson(name); return arg ? null : keyConstantJson(name);
@@ -656,15 +661,17 @@ function shellConstructorToExtendedJson(name: string, args: string[]): string |
return INTEGER_ARGUMENT.test(arg) && fitsIntegerBounds(arg, INT64_BOUNDS) ? `{"$date":{"$numberLong":${JSON.stringify(arg)}}}` : null; return INTEGER_ARGUMENT.test(arg) && fitsIntegerBounds(arg, INT64_BOUNDS) ? `{"$date":{"$numberLong":${JSON.stringify(arg)}}}` : null;
case "NumberLong": case "NumberLong":
case "NumberInt": { case "NumberInt": {
const long = name === "NumberLong" || name === "Long";
const value = literal ?? arg; const value = literal ?? arg;
if (value === undefined || !INTEGER_ARGUMENT.test(value)) return null; if (value === undefined || !INTEGER_ARGUMENT.test(value)) return null;
if (!fitsIntegerBounds(value, name === "NumberLong" ? INT64_BOUNDS : INT32_BOUNDS)) return null; if (!fitsIntegerBounds(value, long ? INT64_BOUNDS : INT32_BOUNDS)) return null;
return wrap(name === "NumberLong" ? "$numberLong" : "$numberInt", value); return wrap(long ? "$numberLong" : "$numberInt", value);
} }
case "NumberDecimal": { case "NumberDecimal":
case "Double": {
const value = literal ?? arg; const value = literal ?? arg;
if (value === undefined || !DECIMAL_ARGUMENT.test(value)) return null; if (value === undefined || !DECIMAL_ARGUMENT.test(value)) return null;
return wrap("$numberDecimal", value); return wrap(name === "Double" ? "$numberDouble" : "$numberDecimal", value);
} }
default: default:
return null; return null;