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}")'],
|
["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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 +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;
|
||||||
|
|||||||
Reference in New Issue
Block a user