diff --git a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts index 35d3adfa6b..385fe21af1 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletionTables.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletionTables.ts @@ -1006,6 +1006,10 @@ export const VALUE_SNIPPETS: MongoOperatorSpec[] = specs([ ["NumberLong", "64-bit integer value", 'NumberLong("${value}")'], ["NumberInt", "32-bit integer value", "NumberInt(${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}")'], ["BinData", "Binary value", 'BinData(0, "${base64}")'], ["Timestamp", "Internal timestamp value", "Timestamp(${seconds}, ${ordinal})"], diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 698ca15698..074aa1b83c 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -118,7 +118,7 @@ const DATABASE_METHOD_SHAPES: Record = { 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*(?[A-Za-z_$][\w$]*)|\[\s*(["'])(?.*?)\2\s*\]|\.\s*getCollection\s*\(\s*(["'])(?.*?)\4\s*\))?\s*\.\s*(?[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. */ 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 escaped = false; for (let index = 0; index < argument.length; index += 1) { diff --git a/crates/dbx-driver-mongodb/src/mongo_shell.rs b/crates/dbx-driver-mongodb/src/mongo_shell.rs index 1f950bf0bc..078e5e4e17 100644 --- a/crates/dbx-driver-mongodb/src/mongo_shell.rs +++ b/crates/dbx-driver-mongodb/src/mongo_shell.rs @@ -1477,13 +1477,18 @@ fn is_shell_regex_value_prefix(previous_significant: Option) -> bool { /// Shell value constructors rewritten to extended JSON before json5 parsing. /// `Date` is only recognised after `new`, matching the shell where a bare `Date()` /// returns a string rather than a date. -const SHELL_CONSTRUCTORS: [&str; 11] = [ +const SHELL_CONSTRUCTORS: [&str; 15] = [ "ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", + // mongosh's BSON type names for the same values. + "Int32", + "Long", + "Double", + "Decimal128", "UUID", "BinData", "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. fn shell_constructor_call(input: &str, index: usize) -> Result, 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 (name_start, is_new) = match rest.strip_prefix("new") { 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 Ok(format!(r#"{{"$date":{{"$numberLong":"{millis}"}}}}"#)), None => Ok(extended_json("$date", &parse_string_arg(inner)?)), }, - "NumberLong" | "NumberInt" => { - let key = if name == "NumberLong" { "$numberLong" } else { "$numberInt" }; + "NumberLong" | "NumberInt" | "Long" | "Int32" => { + let key = if matches!(name, "NumberLong" | "Long") { "$numberLong" } else { "$numberInt" }; let value = match integer { Some(value) => value.to_string(), None => parse_string_arg(inner)?, @@ -1679,13 +1688,13 @@ fn shell_constructor_to_extended_json(name: &str, inner: &str) -> Result().map_err(|_| format!("MongoDB {name}() requires an integer argument."))?; Ok(extended_json(key, &value)) } - "NumberDecimal" => { + "NumberDecimal" | "Decimal128" | "Double" => { let value = match parse_json_value(inner) { Some(Value::Number(number)) => number.to_string(), _ => parse_string_arg(inner)?, }; - value.parse::().map_err(|_| "MongoDB NumberDecimal() requires a numeric argument.".to_string())?; - Ok(extended_json("$numberDecimal", &value)) + value.parse::().map_err(|_| format!("MongoDB {name}() requires a numeric argument."))?; + Ok(extended_json(if name == "Double" { "$numberDouble" } else { "$numberDecimal" }, &value)) } _ => 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}"); } + #[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] fn rewrites_epoch_millisecond_and_numeric_value_constructors() { let MongoCommand::Find { filter, .. } = diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index 118c8792fd..3456101dbb 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -2132,7 +2132,7 @@ test("replaces typed new keyword when completing new Date", () => { // keeps the other `…d…` constructors alongside `new Date`. assert.deepEqual( 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) diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 08bfa76a2c..4b853e2b86 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -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", () => { assert.equal(parseMongoFindCommand("db.orders.find({a: NumberLong(9223372036854775808)})"), null); assert.equal(parseMongoFindCommand("db.orders.find({a: NumberLong(-9223372036854775809)})"), null); diff --git a/packages/mongo-shell/src/json.ts b/packages/mongo-shell/src/json.ts index b078713f65..110cd428f2 100644 --- a/packages/mongo-shell/src/json.ts +++ b/packages/mongo-shell/src/json.ts @@ -14,7 +14,8 @@ export function normalizeJsonArgument(value: string): string | null { const withoutEjsonDeserialize = replaceMongoEjsonDeserialize(withoutComments); // Rewrite mongo shell constructors that are not valid JSON into extended JSON // (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 preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson)); try { @@ -546,7 +547,9 @@ function replaceMongoEjsonDeserialize(source: string): string { * `Date` is only recognised after `new`, matching the shell where a bare `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 = { Int32: "NumberInt", Long: "NumberLong", Decimal128: "NumberDecimal" }; /** `MinKey` / `MaxKey` are also valid without parentheses, as in `{ $lt: MaxKey }`. */ const BARE_KEY_CONSTANT = /^(MinKey|MaxKey)(?![\w$])/; 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. */ 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)); if (!match) return null; const name = match[2]!; @@ -637,7 +642,7 @@ function shellConstructorToExtendedJson(name: string, args: string[]): string | const arg = args[0]?.trim(); const literal = arg ? (QUOTED_ARGUMENT.exec(arg)?.[2] ?? null) : null; - switch (name) { + switch (NUMBER_CONSTRUCTOR_ALIASES[name] ?? name) { case "MinKey": case "MaxKey": 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; case "NumberLong": case "NumberInt": { + const long = name === "NumberLong" || name === "Long"; const value = literal ?? arg; if (value === undefined || !INTEGER_ARGUMENT.test(value)) return null; - if (!fitsIntegerBounds(value, name === "NumberLong" ? INT64_BOUNDS : INT32_BOUNDS)) return null; - return wrap(name === "NumberLong" ? "$numberLong" : "$numberInt", value); + if (!fitsIntegerBounds(value, long ? INT64_BOUNDS : INT32_BOUNDS)) return null; + return wrap(long ? "$numberLong" : "$numberInt", value); } - case "NumberDecimal": { + case "NumberDecimal": + case "Double": { const value = literal ?? arg; if (value === undefined || !DECIMAL_ARGUMENT.test(value)) return null; - return wrap("$numberDecimal", value); + return wrap(name === "Double" ? "$numberDouble" : "$numberDecimal", value); } default: return null;