fix(mongodb): preserve long ids in filters and updates

This commit is contained in:
zipg authored and GitHub committed 2026-07-14 11:09:11 +08:00
1 parent 2666ed1cd2
commit 2a5e7ac1c7
9 files changed
+325 -31

No files matched your search

@@ -3,6 +3,10 @@ export type MongoInputValue = string | number | boolean | null;
const MONGO_SHELL_DATE_PATTERN = /^(?:ISODate|new Date)\(\s*(["'])(.+)\1\s*\)$/;
const LEGACY_MONGO_DATE_DISPLAY_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?$/;
const MONGO_OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
const MONGO_INTEGER_PATTERN = /^-?\d+$/;
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const MIN_BSON_INT64 = -9223372036854775808n;
const MAX_BSON_INT64 = 9223372036854775807n;
export function mongoShellDateToExtendedJson(value: unknown): unknown {
if (typeof value !== "string") return value;
@@ -24,7 +28,14 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown {
const legacyDate = legacyMongoDateDisplayToExtendedJson(trimmed);
if (legacyDate) return legacyDate;
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (MONGO_INTEGER_PATTERN.test(trimmed)) {
const integer = BigInt(trimmed);
if (integer > MAX_SAFE_BIGINT || integer < -MAX_SAFE_BIGINT) {
return integer >= MIN_BSON_INT64 && integer <= MAX_BSON_INT64 ? { $numberLong: trimmed } : trimmed;
}
return Number(trimmed);
}
if (/^-?\d+\.\d+$/.test(trimmed)) return Number(trimmed);
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"')) {
return mongoShellDateToExtendedJson(JSON.parse(trimmed));
}
@@ -155,3 +166,26 @@ export function formatMongoShellLiteral(value: unknown): string {
}
return JSON.stringify(String(value));
}
export function serializeMongoDocumentId(value: unknown): string {
if (typeof value === "string") return `__dbx_mongo_string_id__${JSON.stringify(value)}`;
if (isMongoExtendedJsonId(value)) return JSON.stringify(value);
return String(value);
}
export function mongoDocumentIdForGrid(value: unknown): MongoInputValue {
if (isMongoNumberLong(value)) return value.$numberLong;
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
return JSON.stringify(value);
}
function isMongoExtendedJsonId(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const object = value as Record<string, unknown>;
const keys = Object.keys(object);
return keys.length === 1 && (typeof object.$numberLong === "string" || typeof object.$oid === "string");
}
function isMongoNumberLong(value: unknown): value is { $numberLong: string } {
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1 && typeof (value as Record<string, unknown>).$numberLong === "string";
}
@@ -1,4 +1,5 @@
import type { QueryResult } from "@/types/database";
import { mongoDocumentIdForGrid } from "@/lib/mongo/mongoDocumentValues";
export interface MongoFindCommand {
collection: string;
@@ -638,11 +639,12 @@ function normalizeJsonArgument(value: string): string | null {
if (!trimmed) return "{}";
// Rewrite mongo shell constructors that are not valid JSON into the extended
// JSON the backend understands (mongo_driver::json_value_to_bson): ObjectId(x)
// -> {"$oid":x} and ISODate(x)/new Date(x) -> {"$date":x}. Without this a
// -> {"$oid":x}, NumberLong(x) -> {"$numberLong":x}, and
// ISODate(x)/new Date(x) -> {"$date":x}. Without this a
// filter such as { createdAt: { $gte: ISODate("...") } } fails JSON.parse,
// the command is left unrecognized and falls through to the SQL executor,
// which rejects it with "Use MongoDB-specific commands".
const withExtendedJson = trimmed.replace(/ObjectId\s*\(\s*["']([^"']+)["']\s*\)/g, '{"$oid":"$1"}').replace(/(?:ISODate|new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/g, '{"$date":"$1"}');
const withExtendedJson = replaceMongoShellConstructors(trimmed);
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson));
try {
JSON.parse(preprocessed);
@@ -652,6 +654,41 @@ function normalizeJsonArgument(value: string): string | null {
}
}
function replaceMongoShellConstructors(source: string): string {
const constructor = /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/;
let result = "";
let index = 0;
while (index < source.length) {
const quote = source[index];
if (quote === '"' || quote === "'") {
const start = index++;
while (index < source.length) {
if (source[index] === "\\") index += 2;
else if (source[index] === quote) {
index++;
break;
} else index++;
}
result += source.slice(start, index);
continue;
}
const match = source.slice(index).match(constructor);
if (!match) {
result += source[index++];
continue;
}
if (match[1]) {
result += match[1] === "ObjectId" ? `{"$oid":"${match[2]}"}` : match[1] === "NumberLong" ? `{"$numberLong":"${match[2]}"}` : `{"$date":"${match[2]}"}`;
} else if (match[3]) {
result += match[3] === "NumberLong" ? `{"$numberLong":"${match[4]}"}` : `{"$oid":"${match[4]}"}`;
} else {
result += `{"$date":"${match[5]}"}`;
}
index += match[0].length;
}
return result;
}
function parseMethodArgs(source: string, methodCallIndex: number): string[] | null {
const openIndex = source.indexOf("(", methodCallIndex);
const closeIndex = findMatchingParen(source, openIndex);
@@ -1171,5 +1208,5 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function toCellValue(value: unknown): string | number | boolean | null {
if (value === undefined || value === null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
return JSON.stringify(value);
return mongoDocumentIdForGrid(value);
}