mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(mongodb): accept mongosh number type constructors
This commit is contained in:
@@ -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})"],
|
||||
|
||||
@@ -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_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*\(/;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
/// `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<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 (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<String,
|
||||
Some(millis) => 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<String,
|
||||
value.parse::<i64>().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::<f64>().map_err(|_| "MongoDB NumberDecimal() requires a numeric argument.".to_string())?;
|
||||
Ok(extended_json("$numberDecimal", &value))
|
||||
value.parse::<f64>().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, .. } =
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+28
@@ -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);
|
||||
|
||||
@@ -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<string, string> = { 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;
|
||||
|
||||
Reference in New Issue
Block a user