mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:07:37 +00:00
fix(mongo): support bson shell literals in filters and queries
This commit is contained in:
@@ -2695,7 +2695,15 @@ fn json_filter_value_to_bson(value: &serde_json::Value, field_name: Option<&str>
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
if obj.len() == 1 {
|
||||
if obj.contains_key("$regularExpression") {
|
||||
// Shell constructor wrappers (UUID/BinData/Timestamp/MinKey/MaxKey)
|
||||
// and $regularExpression decode through the shared extended JSON
|
||||
// parser, following the same precedent as $date below.
|
||||
if obj.keys().next().is_some_and(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"$regularExpression" | "$uuid" | "$binary" | "$timestamp" | "$minKey" | "$maxKey"
|
||||
)
|
||||
}) {
|
||||
if let Ok(Some(value)) = parse_extended_json_value(obj) {
|
||||
return value;
|
||||
}
|
||||
@@ -3334,6 +3342,85 @@ mod tests {
|
||||
assert_eq!(op.get("$gte"), Some(&Bson::DateTime(expected)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_filter_to_document_decodes_uuid_binary_timestamp_and_key_constants() {
|
||||
// Equality on shell constructor values must compare against the typed
|
||||
// BSON value, not a raw { "$uuid": ... } document the server rejects
|
||||
// with "unknown operator: $uuid" (or silently matches nothing).
|
||||
let filter = serde_json::json!({
|
||||
"_id": { "$uuid": "3b241101-e2bb-4255-8caf-4136c566a962" },
|
||||
"payload": { "$binary": { "base64": "AQID", "subType": "80" } },
|
||||
"moment": { "$timestamp": { "t": 1735689600, "i": 7 } },
|
||||
"lower": { "$minKey": 1 },
|
||||
"upper": { "$maxKey": 1 },
|
||||
});
|
||||
let doc = json_filter_to_document(&filter).unwrap();
|
||||
|
||||
let expected_uuid = uuid::Uuid::parse_str("3b241101-e2bb-4255-8caf-4136c566a962").unwrap();
|
||||
assert!(matches!(
|
||||
doc.get("_id"),
|
||||
Some(Bson::Binary(binary))
|
||||
if binary.subtype == mongodb::bson::spec::BinarySubtype::Uuid && binary.bytes == expected_uuid.as_bytes()
|
||||
));
|
||||
assert!(matches!(
|
||||
doc.get("payload"),
|
||||
Some(Bson::Binary(binary))
|
||||
if binary.subtype == mongodb::bson::spec::BinarySubtype::UserDefined(0x80) && binary.bytes == [1, 2, 3]
|
||||
));
|
||||
assert!(matches!(
|
||||
doc.get("moment"),
|
||||
Some(Bson::Timestamp(timestamp)) if timestamp.time == 1735689600 && timestamp.increment == 7
|
||||
));
|
||||
assert!(matches!(doc.get("lower"), Some(Bson::MinKey)));
|
||||
assert!(matches!(doc.get("upper"), Some(Bson::MaxKey)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_filter_to_document_decodes_key_constants_inside_operators() {
|
||||
// Range operands must be decoded too, exactly like extended JSON dates:
|
||||
// { score: { $gt: MinKey } } and { _id: { $gt: UUID(...) } } would
|
||||
// otherwise compare against a sub-document and silently match nothing.
|
||||
// The _id case also covers the all-$ operator document branch, where
|
||||
// operators like $gt keep recursing through json_filter_value_to_bson.
|
||||
let filter = serde_json::json!({
|
||||
"score": { "$gt": { "$minKey": 1 }, "$lt": { "$maxKey": 1 } },
|
||||
"_id": { "$gt": { "$uuid": "3b241101-e2bb-4255-8caf-4136c566a962" } },
|
||||
"snapshot": { "$gte": { "$timestamp": { "t": 1735689600, "i": 7 } } },
|
||||
"blob": { "$eq": { "$binary": { "base64": "AQID", "subType": "00" } } },
|
||||
});
|
||||
let doc = json_filter_to_document(&filter).unwrap();
|
||||
|
||||
let Some(Bson::Document(score)) = doc.get("score") else {
|
||||
panic!("expected score operator document");
|
||||
};
|
||||
assert_eq!(score.get("$gt"), Some(&Bson::MinKey));
|
||||
assert_eq!(score.get("$lt"), Some(&Bson::MaxKey));
|
||||
|
||||
let Some(Bson::Document(id_filter)) = doc.get("_id") else {
|
||||
panic!("expected _id operator document");
|
||||
};
|
||||
assert!(matches!(
|
||||
id_filter.get("$gt"),
|
||||
Some(Bson::Binary(binary)) if binary.subtype == mongodb::bson::spec::BinarySubtype::Uuid
|
||||
));
|
||||
|
||||
let Some(Bson::Document(snapshot)) = doc.get("snapshot") else {
|
||||
panic!("expected snapshot operator document");
|
||||
};
|
||||
assert!(matches!(
|
||||
snapshot.get("$gte"),
|
||||
Some(Bson::Timestamp(timestamp)) if timestamp.time == 1735689600 && timestamp.increment == 7
|
||||
));
|
||||
|
||||
let Some(Bson::Document(blob)) = doc.get("blob") else {
|
||||
panic!("expected blob operator document");
|
||||
};
|
||||
assert!(matches!(
|
||||
blob.get("$eq"),
|
||||
Some(Bson::Binary(binary)) if binary.subtype == mongodb::bson::spec::BinarySubtype::Generic && binary.bytes == [1, 2, 3]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bson_to_json_displays_date_as_mongo_isodate() {
|
||||
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::{SecondsFormat, Utc};
|
||||
use mongodb::bson::oid::ObjectId;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
@@ -1065,7 +1066,19 @@ 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; 6] = ["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal"];
|
||||
const SHELL_CONSTRUCTORS: [&str; 11] = [
|
||||
"ObjectId",
|
||||
"ISODate",
|
||||
"Date",
|
||||
"NumberLong",
|
||||
"NumberInt",
|
||||
"NumberDecimal",
|
||||
"UUID",
|
||||
"BinData",
|
||||
"Timestamp",
|
||||
"MinKey",
|
||||
"MaxKey",
|
||||
];
|
||||
|
||||
fn transform_shell_constructors(input: &str) -> Result<String, String> {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
@@ -1096,12 +1109,43 @@ fn transform_shell_constructors(input: &str) -> Result<String, String> {
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if let Some((json, end)) = shell_bare_constant(input, index) {
|
||||
output.push_str(&json);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
output.push(ch);
|
||||
index += ch.len_utf8();
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Rewrite a bare `MinKey` / `MaxKey` at `index`, as in `{ $lt: MaxKey }`.
|
||||
/// A following `:` means it is an object key, not a value.
|
||||
fn shell_bare_constant(input: &str, index: usize) -> Option<(String, usize)> {
|
||||
if input[..index].ends_with(|ch: char| ch.is_alphanumeric() || matches!(ch, '_' | '$' | '.')) {
|
||||
return None;
|
||||
}
|
||||
let rest = &input[index..];
|
||||
let name = ["MinKey", "MaxKey"].into_iter().find(|name| rest.starts_with(name))?;
|
||||
let after = &rest[name.len()..];
|
||||
if after.starts_with(|ch: char| ch.is_alphanumeric() || matches!(ch, '_' | '$')) {
|
||||
return None;
|
||||
}
|
||||
if after.trim_start().starts_with([':', '(']) {
|
||||
return None;
|
||||
}
|
||||
Some((key_constant_json(name), index + name.len()))
|
||||
}
|
||||
|
||||
fn key_constant_json(name: &str) -> String {
|
||||
if name == "MinKey" {
|
||||
r#"{"$minKey":1}"#.to_string()
|
||||
} else {
|
||||
r#"{"$maxKey":1}"#.to_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> {
|
||||
let rest = &input[index..];
|
||||
@@ -1133,8 +1177,15 @@ fn shell_constructor_call(input: &str, index: usize) -> Result<Option<(String, u
|
||||
}
|
||||
|
||||
fn shell_constructor_to_extended_json(name: &str, inner: &str) -> Result<String, String> {
|
||||
if matches!(name, "BinData" | "Timestamp") {
|
||||
return two_argument_constructor_to_extended_json(name, inner);
|
||||
}
|
||||
let integer = inner.parse::<i64>().ok();
|
||||
match name {
|
||||
"MinKey" | "MaxKey" if inner.is_empty() => Ok(key_constant_json(name)),
|
||||
"MinKey" | "MaxKey" => Err(format!("MongoDB {name}() takes no arguments.")),
|
||||
"UUID" if inner.is_empty() => Ok(extended_json("$uuid", &Uuid::new_v4().to_string())),
|
||||
"UUID" => Ok(extended_json("$uuid", &parse_uuid_arg(inner)?)),
|
||||
"ObjectId" if inner.is_empty() => Ok(extended_json("$oid", &ObjectId::new().to_hex())),
|
||||
"ObjectId" => Ok(extended_json("$oid", &parse_string_arg(inner)?)),
|
||||
"ISODate" | "Date" if inner.is_empty() => {
|
||||
@@ -1167,6 +1218,51 @@ fn shell_constructor_to_extended_json(name: &str, inner: &str) -> Result<String,
|
||||
}
|
||||
}
|
||||
|
||||
/// `UUID("...")` takes the canonical 8-4-4-4-12 hex form, matching mongosh
|
||||
/// (hex digits only, dashes required, upper or lower case).
|
||||
fn parse_uuid_arg(arg: &str) -> Result<String, String> {
|
||||
let value = parse_string_arg(arg)?;
|
||||
if is_canonical_uuid(&value) {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err("MongoDB UUID() requires a canonical 8-4-4-4-12 hex string.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_canonical_uuid(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() == 36
|
||||
&& bytes.iter().enumerate().all(|(index, byte)| match index {
|
||||
8 | 13 | 18 | 23 => *byte == b'-',
|
||||
_ => byte.is_ascii_hexdigit(),
|
||||
})
|
||||
}
|
||||
|
||||
fn two_argument_constructor_to_extended_json(name: &str, inner: &str) -> Result<String, String> {
|
||||
let args = split_top_level(inner);
|
||||
if args.len() != 2 {
|
||||
return Err(format!("MongoDB {name}() requires exactly two arguments."));
|
||||
}
|
||||
if name == "Timestamp" {
|
||||
// Timestamp(t, i): two unsigned 32-bit integers, seconds and ordinal.
|
||||
let parse = |value: &str| value.trim().parse::<u32>();
|
||||
return match (parse(&args[0]), parse(&args[1])) {
|
||||
(Ok(seconds), Ok(ordinal)) => Ok(format!(r#"{{"$timestamp":{{"t":{seconds},"i":{ordinal}}}}}"#)),
|
||||
_ => Err("MongoDB Timestamp() requires two unsigned 32-bit integers.".to_string()),
|
||||
};
|
||||
}
|
||||
// BinData(subType, base64): extended JSON carries the subtype as two hex digits.
|
||||
let sub_type = args[0]
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.map_err(|_| "MongoDB BinData() subtype must be an integer from 0 to 255.".to_string())?;
|
||||
let base64 = parse_string_arg(&args[1])?;
|
||||
Ok(format!(
|
||||
r#"{{"$binary":{{"base64":{},"subType":"{sub_type:02x}"}}}}"#,
|
||||
serde_json::to_string(&base64).unwrap_or_else(|_| "null".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
fn extended_json(key: &str, value: &str) -> String {
|
||||
format!("{{\"{key}\":{}}}", serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()))
|
||||
}
|
||||
@@ -1570,6 +1666,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_uuid_binary_timestamp_and_key_constants() {
|
||||
let MongoCommand::Find { filter, .. } = parse(
|
||||
r#"db.c.find({
|
||||
u: UUID("3b241101-e2bb-4255-8caf-4136c566a962"),
|
||||
b: BinData(128, "AQID"),
|
||||
t: Timestamp(1735689600, 7),
|
||||
lo: MinKey,
|
||||
hi: MaxKey(),
|
||||
range: {$gt: MinKey(), $lt: MaxKey},
|
||||
list: [MinKey, MaxKey]
|
||||
})"#,
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!("expected a find command");
|
||||
};
|
||||
assert_eq!(
|
||||
parse_json_value(&filter).unwrap(),
|
||||
serde_json::json!({
|
||||
"u": {"$uuid": "3b241101-e2bb-4255-8caf-4136c566a962"},
|
||||
"b": {"$binary": {"base64": "AQID", "subType": "80"}},
|
||||
"t": {"$timestamp": {"t": 1735689600, "i": 7}},
|
||||
"lo": {"$minKey": 1},
|
||||
"hi": {"$maxKey": 1},
|
||||
"range": {"$gt": {"$minKey": 1}, "$lt": {"$maxKey": 1}},
|
||||
"list": [{"$minKey": 1}, {"$maxKey": 1}],
|
||||
})
|
||||
);
|
||||
|
||||
let MongoCommand::Find { filter, .. } = parse("db.c.find({u: UUID()})").unwrap() else {
|
||||
panic!("expected a find command");
|
||||
};
|
||||
let generated = parse_json_value(&filter).unwrap()["u"]["$uuid"].as_str().unwrap().to_string();
|
||||
assert!(Uuid::parse_str(&generated).is_ok(), "{generated}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_key_constant_names_alone_outside_value_positions() {
|
||||
let MongoCommand::Find { filter, .. } =
|
||||
parse(r#"db.c.find({MinKey: 1, MaxKey: 2, label: "MinKey", nested: {MaxKey: true}})"#).unwrap()
|
||||
else {
|
||||
panic!("expected a find command");
|
||||
};
|
||||
assert_eq!(filter, r#"{"MinKey":1,"MaxKey":2,"label":"MinKey","nested":{"MaxKey":true}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_uuid_binary_timestamp_and_key_constants() {
|
||||
for (source, expected) in [
|
||||
(r#"db.c.find({b: BinData(256, "x")})"#, "BinData"),
|
||||
(r#"db.c.find({b: BinData(0)})"#, "BinData"),
|
||||
(r#"db.c.find({b: BinData("00", "x")})"#, "BinData"),
|
||||
(r#"db.c.find({t: Timestamp(1)})"#, "Timestamp"),
|
||||
(r#"db.c.find({t: Timestamp(-1, 0)})"#, "Timestamp"),
|
||||
(r#"db.c.find({t: Timestamp(4294967296, 0)})"#, "Timestamp"),
|
||||
(r#"db.c.find({u: UUID(1)})"#, "string"),
|
||||
(r#"db.c.find({a: MinKey(1)})"#, "MinKey"),
|
||||
(r#"db.c.find({a: MaxKey("x")})"#, "MaxKey"),
|
||||
] {
|
||||
let error = parse(source).unwrap_err();
|
||||
assert!(error.contains(expected), "{source} => {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_uuid_strings_at_parse_time() {
|
||||
// mongosh requires the canonical 8-4-4-4-12 hex form: dashes at fixed
|
||||
// positions, hex digits elsewhere, upper or lower case.
|
||||
for source in [
|
||||
r#"db.c.find({u: UUID("3b241101e2bb42558caf4136c566a962")})"#,
|
||||
r#"db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a96")})"#,
|
||||
r#"db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a9620")})"#,
|
||||
r#"db.c.find({u: UUID("zb241101-e2bb-4255-8caf-4136c566a962")})"#,
|
||||
r#"db.c.find({u: UUID("3b241101_e2bb_4255_8caf_4136c566a962")})"#,
|
||||
] {
|
||||
let error = parse(source).unwrap_err();
|
||||
assert!(error.contains("UUID() requires"), "{source} => {error}");
|
||||
}
|
||||
|
||||
// Upper-case hex is accepted and preserved verbatim.
|
||||
let MongoCommand::Find { filter, .. } =
|
||||
parse(r#"db.c.find({u: UUID("3B241101-E2BB-4255-8CAF-4136C566A962")})"#).unwrap()
|
||||
else {
|
||||
panic!("expected a find command");
|
||||
};
|
||||
assert_eq!(filter, r#"{"u":{"$uuid":"3B241101-E2BB-4255-8CAF-4136C566A962"}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_show_databases_aliases_as_read_only() {
|
||||
for source in [
|
||||
|
||||
+66
@@ -380,6 +380,72 @@ test('parseMongoCommand accepts db["name"] bracket collection accessors', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("parseMongoFindCommand rewrites UUID, BinData, Timestamp and MinKey/MaxKey", () => {
|
||||
const command = parseMongoFindCommand(`db.c.find({
|
||||
u: UUID("3b241101-e2bb-4255-8caf-4136c566a962"),
|
||||
b: BinData(128, "AQID"),
|
||||
t: Timestamp(1735689600, 7),
|
||||
lo: MinKey,
|
||||
hi: MaxKey(),
|
||||
range: {$gt: MinKey(), $lt: MaxKey},
|
||||
list: [MinKey, MaxKey]
|
||||
})`);
|
||||
assert.ok(command);
|
||||
assert.deepEqual(JSON.parse(command.filter), {
|
||||
u: { $uuid: "3b241101-e2bb-4255-8caf-4136c566a962" },
|
||||
b: { $binary: { base64: "AQID", subType: "80" } },
|
||||
t: { $timestamp: { t: 1735689600, i: 7 } },
|
||||
lo: { $minKey: 1 },
|
||||
hi: { $maxKey: 1 },
|
||||
range: { $gt: { $minKey: 1 }, $lt: { $maxKey: 1 } },
|
||||
list: [{ $minKey: 1 }, { $maxKey: 1 }],
|
||||
});
|
||||
|
||||
const generated = JSON.parse(parseMongoFindCommand("db.c.find({u: UUID()})")!.filter).u.$uuid;
|
||||
assert.match(generated, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
test("parseMongoFindCommand leaves MinKey/MaxKey alone outside value positions", () => {
|
||||
const command = parseMongoFindCommand('db.c.find({MinKey: 1, MaxKey: 2, label: "MinKey", nested: {MaxKey: true}})');
|
||||
assert.ok(command);
|
||||
assert.deepEqual(JSON.parse(command.filter), { MinKey: 1, MaxKey: 2, label: "MinKey", nested: { MaxKey: true } });
|
||||
});
|
||||
|
||||
test("parseMongoFindCommand rejects malformed UUID, BinData, Timestamp and MinKey/MaxKey", () => {
|
||||
for (const source of [
|
||||
'db.c.find({b: BinData(256, "x")})',
|
||||
"db.c.find({b: BinData(0)})",
|
||||
'db.c.find({b: BinData("00", "x")})',
|
||||
"db.c.find({t: Timestamp(1)})",
|
||||
"db.c.find({t: Timestamp(-1, 0)})",
|
||||
"db.c.find({t: Timestamp(4294967296, 0)})",
|
||||
"db.c.find({u: UUID(1)})",
|
||||
"db.c.find({a: MinKey(1)})",
|
||||
'db.c.find({a: MaxKey("x")})',
|
||||
]) {
|
||||
assert.equal(parseMongoFindCommand(source), null, source);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseMongoFindCommand validates UUID strings at parse time", () => {
|
||||
// mongosh requires the canonical 8-4-4-4-12 hex form: dashes at fixed
|
||||
// positions, hex digits elsewhere.
|
||||
for (const source of [
|
||||
'db.c.find({u: UUID("3b241101e2bb42558caf4136c566a962")})',
|
||||
'db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a96")})',
|
||||
'db.c.find({u: UUID("3b241101-e2bb-4255-8caf-4136c566a9620")})',
|
||||
'db.c.find({u: UUID("zb241101-e2bb-4255-8caf-4136c566a962")})',
|
||||
'db.c.find({u: UUID("3b241101_e2bb_4255_8caf_4136c566a962")})',
|
||||
]) {
|
||||
assert.equal(parseMongoFindCommand(source), null, source);
|
||||
}
|
||||
|
||||
// Upper-case hex is accepted and preserved verbatim.
|
||||
const command = parseMongoFindCommand('db.c.find({u: UUID("3B241101-E2BB-4255-8CAF-4136C566A962")})');
|
||||
assert.ok(command);
|
||||
assert.deepEqual(JSON.parse(command.filter), { u: { $uuid: "3B241101-E2BB-4255-8CAF-4136C566A962" } });
|
||||
});
|
||||
|
||||
test("parseMongoFindCommand accepts single-quoted string values and unquoted sort keys", () => {
|
||||
const command = parseMongoFindCommand("db.products.find({category: 'Electronics'}).sort({price: -1}).limit(2)");
|
||||
assert.ok(command);
|
||||
|
||||
@@ -14,7 +14,7 @@ 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.
|
||||
// NumberInt / NumberDecimal / UUID / BinData / Timestamp / MinKey / MaxKey.
|
||||
const withExtendedJson = replaceMongoShellConstructors(withoutEjsonDeserialize);
|
||||
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson));
|
||||
try {
|
||||
@@ -546,11 +546,15 @@ 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"]);
|
||||
const SHELL_CONSTRUCTORS = new Set(["ObjectId", "ISODate", "Date", "NumberLong", "NumberInt", "NumberDecimal", "UUID", "BinData", "Timestamp", "MinKey", "MaxKey"]);
|
||||
/** `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*\(/;
|
||||
const QUOTED_ARGUMENT = /^(["'])([^\\]*)\1$/;
|
||||
const INTEGER_ARGUMENT = /^-?\d+$/;
|
||||
const DECIMAL_ARGUMENT = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
||||
/** Canonical 8-4-4-4-12 hex form, as mongosh requires for `UUID("...")`. */
|
||||
const UUID_ARGUMENT = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
|
||||
const INT64_BOUNDS = [-9223372036854775808n, 9223372036854775807n] as const;
|
||||
const INT32_BOUNDS = [-2147483648n, 2147483647n] as const;
|
||||
@@ -582,7 +586,7 @@ function replaceMongoShellConstructors(source: string): string {
|
||||
result += source.slice(start, index);
|
||||
continue;
|
||||
}
|
||||
const call = matchShellConstructorCall(source, index);
|
||||
const call = matchShellConstructorCall(source, index) ?? matchShellBareConstant(source, index);
|
||||
if (!call) {
|
||||
result += source[index++]!;
|
||||
continue;
|
||||
@@ -593,6 +597,21 @@ function replaceMongoShellConstructors(source: string): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Rewrite a bare `MinKey` / `MaxKey` at {@link index}; a following `:` means it is an object key, not a value. */
|
||||
function matchShellBareConstant(source: string, index: number): { json: string; end: number } | null {
|
||||
if (index > 0 && /[\w$.]/.test(source[index - 1]!)) return null;
|
||||
const match = BARE_KEY_CONSTANT.exec(source.slice(index));
|
||||
if (!match) return null;
|
||||
const end = index + match[0].length;
|
||||
const following = source.slice(end).trimStart();
|
||||
if (following.startsWith(":") || following.startsWith("(")) return null;
|
||||
return { json: keyConstantJson(match[1]!), end };
|
||||
}
|
||||
|
||||
function keyConstantJson(name: string): string {
|
||||
return name === "MinKey" ? '{"$minKey":1}' : '{"$maxKey":1}';
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const match = CONSTRUCTOR_CALL.exec(source.slice(index));
|
||||
@@ -612,11 +631,19 @@ function matchShellConstructorCall(source: string, index: number): { json: strin
|
||||
}
|
||||
|
||||
function shellConstructorToExtendedJson(name: string, args: string[]): string | null {
|
||||
// Two-argument constructors first; everything else takes at most one.
|
||||
if (name === "BinData" || name === "Timestamp") return twoArgumentConstructorToExtendedJson(name, args);
|
||||
if (args.length > 1) return null;
|
||||
const arg = args[0]?.trim();
|
||||
const literal = arg ? (QUOTED_ARGUMENT.exec(arg)?.[2] ?? null) : null;
|
||||
|
||||
switch (name) {
|
||||
case "MinKey":
|
||||
case "MaxKey":
|
||||
return arg ? null : keyConstantJson(name);
|
||||
case "UUID":
|
||||
if (!arg) return wrap("$uuid", generateUuid());
|
||||
return literal !== null && UUID_ARGUMENT.test(literal) ? wrap("$uuid", literal) : null;
|
||||
case "ObjectId":
|
||||
if (!arg) return wrap("$oid", generateObjectIdHex());
|
||||
return literal !== null || INTEGER_ARGUMENT.test(arg) ? wrap("$oid", literal ?? arg) : null;
|
||||
@@ -644,6 +671,35 @@ function shellConstructorToExtendedJson(name: string, args: string[]): string |
|
||||
}
|
||||
}
|
||||
|
||||
const UINT32_BOUNDS = [0n, 4294967295n] as const;
|
||||
const BINARY_SUBTYPE_BOUNDS = [0n, 255n] as const;
|
||||
|
||||
function twoArgumentConstructorToExtendedJson(name: "BinData" | "Timestamp", args: string[]): string | null {
|
||||
if (args.length !== 2) return null;
|
||||
const [first, second] = args.map((value) => value.trim()) as [string, string];
|
||||
if (name === "Timestamp") {
|
||||
// Timestamp(t, i): two unsigned 32-bit integers, seconds and ordinal.
|
||||
if (!INTEGER_ARGUMENT.test(first) || !INTEGER_ARGUMENT.test(second)) return null;
|
||||
if (!fitsIntegerBounds(first, UINT32_BOUNDS) || !fitsIntegerBounds(second, UINT32_BOUNDS)) return null;
|
||||
return `{"$timestamp":{"t":${first},"i":${second}}}`;
|
||||
}
|
||||
// BinData(subType, base64): extended JSON carries the subtype as two hex digits.
|
||||
const base64 = QUOTED_ARGUMENT.exec(second)?.[2];
|
||||
if (base64 === undefined || !INTEGER_ARGUMENT.test(first) || !fitsIntegerBounds(first, BINARY_SUBTYPE_BOUNDS)) return null;
|
||||
const subType = Number(first).toString(16).padStart(2, "0");
|
||||
return `{"$binary":{"base64":${JSON.stringify(base64)},"subType":${JSON.stringify(subType)}}}`;
|
||||
}
|
||||
|
||||
/** Client-side UUID for a bare `UUID()`, mirroring how the shell fills one in. */
|
||||
function generateUuid(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||
const hex = randomHex(16).split("");
|
||||
hex[12] = "4";
|
||||
hex[16] = "89ab"[Number.parseInt(hex[16]!, 16) & 3]!;
|
||||
const raw = hex.join("");
|
||||
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
|
||||
}
|
||||
|
||||
function wrap(key: string, value: string): string {
|
||||
return `{${JSON.stringify(key)}:${JSON.stringify(value)}}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user