fix(mongo): support bson shell literals in filters and queries
This commit is contained in:
1 parent
1ceeaad8a4
commit
71cc734cdd
4 files changed
+398
-5
No files matched your search
@@ -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