diff --git a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java index fed2dbd6dc..743775736b 100644 --- a/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java +++ b/agents/drivers/mongodb/src/main/java/com/dbx/agent/mongodb/MongoAgent.java @@ -632,7 +632,19 @@ public final class MongoAgent { return Collections.singletonMap("inserted_id", insertedId); } - private static Object parseId(String id) { + static Object parseId(String id) { + String stringId = decodeStringDocumentId(id); + if (stringId != null) { + return stringId; + } + String trimmed = id.trim(); + if (isNumberLongIdWrapper(trimmed)) { + try { + return Document.parse("{\"_id\":" + trimmed + "}").get("_id"); + } catch (Exception e) { + // Fall through to the legacy ObjectId/string handling below. + } + } try { return new ObjectId(id); } catch (Exception e) { @@ -640,6 +652,36 @@ public final class MongoAgent { } } + private static String decodeStringDocumentId(String id) { + String prefix = "__dbx_mongo_string_id__"; + if (!id.startsWith(prefix)) { + return null; + } + try { + JsonElement value = JsonParser.parseString(id.substring(prefix.length())); + return value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : null; + } catch (Exception e) { + return null; + } + } + + private static boolean isNumberLongIdWrapper(String value) { + try { + JsonElement parsed = JsonParser.parseString(value); + if (!parsed.isJsonObject()) { + return false; + } + JsonObject wrapper = parsed.getAsJsonObject(); + JsonElement numberLong = wrapper.get("$numberLong"); + return wrapper.size() == 1 + && numberLong != null + && numberLong.isJsonPrimitive() + && numberLong.getAsJsonPrimitive().isString(); + } catch (Exception e) { + return false; + } + } + private static Object updateDocument(JsonObject params) { MongoClient c = requireClient(); String database = params.get("database").getAsString(); @@ -765,11 +807,18 @@ public final class MongoAgent { private static Map bsonToJson(Document doc) { Map result = new LinkedHashMap<>(); for (Map.Entry entry : doc.entrySet()) { - result.put(entry.getKey(), convertValue(entry.getValue())); + result.put(entry.getKey(), convertDocumentFieldValue(entry.getKey(), entry.getValue())); } return result; } + static Object convertDocumentFieldValue(String key, Object value) { + if ("_id".equals(key) && value instanceof Long longValue) { + return Collections.singletonMap("$numberLong", longValue.toString()); + } + return convertValue(value); + } + static JsonObject bsonToExtendedJson(Document doc) { return JsonParser.parseString(doc.toJson(EXTENDED_JSON_SETTINGS)).getAsJsonObject(); } diff --git a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java index 7353c87a75..d7f8a93df7 100644 --- a/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java +++ b/agents/drivers/mongodb/src/test/java/com/dbx/agent/mongodb/MongoAgentTest.java @@ -19,6 +19,7 @@ import java.nio.file.Path; import java.security.KeyStore; import java.security.PrivateKey; import java.util.Base64; +import java.util.Collections; import java.util.Date; import org.bson.Document; import org.bson.types.ObjectId; @@ -81,6 +82,14 @@ class MongoAgentTest { // ─── existing tests ─── + @Test + void parsesExplicitStringDocumentIdsWithoutTreatingThemAsExtendedJson() { + assertEquals( + "{\"$numberLong\":\"2048938405781032962\"}", + MongoAgent.parseId("__dbx_mongo_string_id__\"{\\\"$numberLong\\\":\\\"2048938405781032962\\\"}\"") + ); + } + @Test void exposesProtocolHandshakeOverJsonRpc() { String response = MongoAgent.handleRequest( @@ -159,6 +168,27 @@ class MongoAgentTest { assertEquals(9_007_199_254_740_993L, filter.get("snowflake")); } + @Test + void preservesLongDocumentIdTypeForGridUpdates() { + Object id = MongoAgent.convertDocumentFieldValue("_id", 2_048_938_405_781_032_962L); + Object value = MongoAgent.convertDocumentFieldValue("snowflake", 2_048_938_405_781_032_962L); + + assertEquals(Collections.singletonMap("$numberLong", "2048938405781032962"), id); + assertEquals("2048938405781032962", value); + assertEquals(2_048_938_405_781_032_962L, MongoAgent.parseId("{\"$numberLong\":\"2048938405781032962\"}")); + } + + @Test + void preservesJsonLookingStringDocumentIds() { + assertEquals("{}", MongoAgent.parseId("{}")); + assertEquals("{\"tenant\":1}", MongoAgent.parseId("{\"tenant\":1}")); + assertEquals( + "{\"$numberLong\":\"2048938405781032962\",\"tenant\":1}", + MongoAgent.parseId("{\"$numberLong\":\"2048938405781032962\",\"tenant\":1}") + ); + assertEquals("{\"$numberLong\":\"invalid\"}", MongoAgent.parseId("{\"$numberLong\":\"invalid\"}")); + } + @Test void serverVersionMethodIsRecognizedOverJsonRpc() { String response = MongoAgent.handleRequest( diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 776de3e135..4d8837647e 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -28,7 +28,7 @@ import { type DocumentFilterMode, type DocumentFilterRule, } from "@/lib/app/documentStoreProvider"; -import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { useSettingsStore } from "@/stores/settingsStore"; import JsonEditNode from "./JsonEditNode.vue"; @@ -131,8 +131,9 @@ const deleteDetails = computed(() => { if (!pending) return ""; if (pending.kind === "document") { const id = documents.value[pending.index]?._id ?? ""; - if (props.databaseType === "elasticsearch") return `Elasticsearch index: ${props.collection}\nDocument _id: ${String(id)}`; - return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(id) }); + const displayId = mongoDocumentIdForGrid(id); + if (props.databaseType === "elasticsearch") return `Elasticsearch index: ${props.collection}\nDocument _id: ${String(displayId)}`; + return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(displayId) }); } return t("dangerDialog.mongoFieldDetails", { field: pending.name || t("mongo.field") }); }); @@ -162,6 +163,7 @@ const gridResult = computed(() => { columns.map((col) => { const val = doc[col]; if (val === undefined || val === null) return null; + if (col === "_id") return mongoDocumentIdForGrid(val); if (typeof val === "object") return JSON.stringify(val); if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") return val; return String(val); @@ -254,6 +256,19 @@ function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) function documentIdFromGridValue(value: MongoInputValue | undefined): string | null { if (value === null || value === undefined) return null; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('"')) { + try { + const parsed = JSON.parse(trimmed); + return typeof parsed === "string" && parsed.trim() ? parsed : trimmed; + } catch { + return trimmed; + } + } + return trimmed; + } const parsed = parseMongoDocumentInputValue(value); if (parsed === null || parsed === undefined) return null; const id = typeof parsed === "object" ? JSON.stringify(parsed) : String(parsed); @@ -307,15 +322,18 @@ async function gridSave(changes: DocumentGridChanges) { const updateDoc = buildMongoUpdateDocument(dirtyCols, cols, documents.value[rowIdx]); if (Object.keys(updateDoc).length === 0) continue; - await api.documentUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(updateDoc)); + const documentId = documents.value[rowIdx]?._id ?? id; + await api.documentUpdateDocument(props.connectionId, props.database, props.collection, serializeMongoDocumentId(documentId), JSON.stringify(updateDoc)); } for (const rowIdx of changes.deletedRows) { const row = changes.rows[rowIdx]; const id = row?.[idColIdx]; if (id == null) continue; - const routing = isEs ? documentRoutingFromDocument(documents.value[rowIdx]) : undefined; - await api.documentDeleteDocument(props.connectionId, props.database, props.collection, String(id), routing); + const document = documents.value[rowIdx]; + const routing = isEs ? documentRoutingFromDocument(document) : undefined; + const documentId = isEs ? id : (document?._id ?? id); + await api.documentDeleteDocument(props.connectionId, props.database, props.collection, isEs ? String(documentId) : serializeMongoDocumentId(documentId), routing); } for (const newRow of changes.newRows) { @@ -335,17 +353,10 @@ async function gridSave(changes: DocumentGridChanges) { await load(); } -function formatMongoValue(val: unknown): string { - if (val === null || val === undefined) return "null"; - if (typeof val === "number" || typeof val === "boolean") return String(val); - if (typeof val === "string") return JSON.stringify(val); - return JSON.stringify(val); -} - function mongoIdPreview(val: unknown): string { if (val === null || val === undefined) return "null"; if (typeof val === "string" && /^[a-fA-F0-9]{24}$/.test(val)) return `ObjectId("${val}")`; - return formatMongoValue(val); + return formatMongoShellLiteral(val); } function elasticsearchPathIdPreview(id: string): string { @@ -383,7 +394,7 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise)._id ?? fallback; +} const mongoQueryResultSaveHandler = computed(() => { const tab = props.activeTab; const target = tab.mongoEditTarget; @@ -398,7 +403,7 @@ const mongoQueryResultSaveHandler = computed(() = if (id === null || id === undefined || String(id).trim() === "") continue; const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns, tab.result?.mongo_documents?.[rowIdx]); if (Object.keys(updateDoc).length === 0) continue; - await api.mongoUpdateDocument(tab.connectionId, tab.database, target.collection, String(id), JSON.stringify(updateDoc)); + await api.mongoUpdateDocument(tab.connectionId, tab.database, target.collection, serializeMongoDocumentId(mongoQueryResultDocumentId(rowIdx, id)), JSON.stringify(updateDoc)); } }; @@ -412,7 +417,7 @@ const mongoQueryResultSaveHandler = computed(() = if (id === null || id === undefined || String(id).trim() === "") continue; const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns, tab.result?.mongo_documents?.[rowIdx]); if (Object.keys(updateDoc).length === 0) continue; - stmts.push(`${mongoCollectionExpression(target.collection)}.updateOne({_id: ${mongoIdPreview(id)}}, ${formatMongoShellLiteral(updateDoc)})`); + stmts.push(`${mongoCollectionExpression(target.collection)}.updateOne({_id: ${mongoIdPreview(mongoQueryResultDocumentId(rowIdx, id))}}, ${formatMongoShellLiteral(updateDoc)})`); } return stmts; }; diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index ce21e7b04d..80e394c9f0 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -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 { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const object = value as Record; + 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).$numberLong === "string"; +} diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index b04cd6f02b..76dac780df 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -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 { 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); } diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 36d26544a6..49aa7ccc93 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -991,6 +991,12 @@ pub async fn delete_document(client: &Client, database: &str, collection: &str, } fn document_id_filters(id: &str) -> Vec { + if let Some(string_id) = decode_string_document_id(id) { + return vec![doc! { "_id": Bson::String(string_id) }]; + } + if let Some(filter) = extended_json_document_id_filter(id) { + return vec![filter]; + } let string_filter = doc! { "_id": Bson::String(id.to_string()) }; match ObjectId::parse_str(id) { Ok(oid) => vec![doc! { "_id": Bson::ObjectId(oid) }, string_filter], @@ -998,6 +1004,23 @@ fn document_id_filters(id: &str) -> Vec { } } +fn decode_string_document_id(id: &str) -> Option { + id.strip_prefix("__dbx_mongo_string_id__").and_then(|payload| serde_json::from_str::(payload).ok()) +} + +fn extended_json_document_id_filter(id: &str) -> Option { + let trimmed = id.trim(); + if !trimmed.starts_with('{') { + return None; + } + let value: serde_json::Value = serde_json::from_str(trimmed).ok()?; + let bson = json_value_to_bson(&value); + if matches!(bson, Bson::Document(_)) { + return None; + } + Some(doc! { "_id": bson }) +} + pub async fn delete_documents( client: &Client, database: &str, @@ -1034,7 +1057,7 @@ fn bson_to_json(bson: &Bson) -> serde_json::Value { Bson::Document(doc) => { let mut map = serde_json::Map::new(); for (k, v) in doc { - map.insert(k.clone(), bson_to_json(v)); + map.insert(k.clone(), bson_document_field_to_json(k, v)); } serde_json::Value::Object(map) } @@ -1042,6 +1065,15 @@ fn bson_to_json(bson: &Bson) -> serde_json::Value { } } +fn bson_document_field_to_json(key: &str, bson: &Bson) -> serde_json::Value { + if key == "_id" { + if let Bson::Int64(value) = bson { + return serde_json::json!({ "$numberLong": value.to_string() }); + } + } + bson_to_json(bson) +} + /// Convert a `serde_json::Value` (JSON object) to a BSON `Document`, /// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`. pub fn json_object_to_document(value: &serde_json::Value) -> Result { @@ -1142,6 +1174,9 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson { return Bson::ObjectId(oid); } } + if let Some(value) = parse_extended_json_int64(obj) { + return Bson::Int64(value); + } if let Some(date) = parse_extended_json_date(obj) { return Bson::DateTime(date); } @@ -1207,6 +1242,14 @@ fn parse_extended_json_date(obj: &serde_json::Map) -> } } +fn parse_extended_json_int64(obj: &serde_json::Map) -> Option { + match obj.get("$numberLong")? { + serde_json::Value::String(value) => value.parse().ok(), + serde_json::Value::Number(value) => value.as_i64(), + _ => None, + } +} + fn json_filter_value_to_bson(value: &serde_json::Value, field_name: Option<&str>) -> Bson { if field_name == Some("_id") { if let Some(id) = value.as_str() { @@ -1225,6 +1268,9 @@ fn json_filter_value_to_bson(value: &serde_json::Value, field_name: Option<&str> return Bson::ObjectId(oid); } } + if let Some(value) = parse_extended_json_int64(obj) { + return Bson::Int64(value); + } // Extended JSON dates must be decoded in filters too, otherwise // {"$date": ...} reaches the server as a raw document: a bare // { field: {"$date": ...} } fails with "unknown operator: $date" @@ -1373,7 +1419,7 @@ mod tests { #[test] fn document_id_filters_try_object_id_then_string_for_hex_ids() { let id = "507f1f77bcf86cd799439011"; - let filters = document_id_filters(id); + let filters = document_id_filters(&id); assert_eq!(filters.len(), 2); assert!(matches!(filters[0].get("_id"), Some(Bson::ObjectId(_)))); @@ -1383,12 +1429,40 @@ mod tests { #[test] fn document_id_filters_use_string_only_for_non_hex_ids() { let id = "customer-42"; - let filters = document_id_filters(id); + let filters = document_id_filters(&id); assert_eq!(filters.len(), 1); assert!(matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == id)); } + #[test] + fn document_id_filters_preserve_extended_json_int64_ids() { + let filters = document_id_filters(r#"{"$numberLong":"2048938405781032962"}"#); + + assert_eq!(filters.len(), 1); + assert!(matches!(filters[0].get("_id"), Some(Bson::Int64(2_048_938_405_781_032_962)))); + } + + #[test] + fn document_id_filters_decode_explicit_string_ids_before_extended_json() { + let original = r#"{"$numberLong":"2048938405781032962"}"#; + let id = format!("__dbx_mongo_string_id__{}", serde_json::to_string(original).unwrap()); + let filters = document_id_filters(&id); + + assert_eq!(filters.len(), 1); + assert!( + matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == r####"{"$numberLong":"2048938405781032962"}"####) + ); + } + + #[test] + fn json_filter_to_document_preserves_extended_json_int64_values() { + let filter = serde_json::json!({ "snowflake": { "$numberLong": "2048938405781032962" } }); + let document = json_filter_to_document(&filter).unwrap(); + + assert!(matches!(document.get("snowflake"), Some(Bson::Int64(2_048_938_405_781_032_962)))); + } + #[test] fn json_filter_to_document_matches_object_id_and_string_for_id_hex() { let id = "507f1f77bcf86cd799439011"; @@ -1468,6 +1542,17 @@ mod tests { assert_eq!(value, serde_json::json!("2326645729978441729")); } + #[test] + fn bson_to_json_preserves_int64_id_type_for_updates() { + let value = bson_to_json(&Bson::Document(doc! { + "_id": Bson::Int64(2_048_938_405_781_032_962), + "snowflake": Bson::Int64(2_048_938_405_781_032_962), + })); + + assert_eq!(value["_id"], serde_json::json!({ "$numberLong": "2048938405781032962" })); + assert_eq!(value["snowflake"], serde_json::json!("2048938405781032962")); + } + #[test] fn bson_to_json_keeps_safe_int64_as_number() { let value = bson_to_json(&Bson::Int64(42)); diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index 52ac421c45..d9fdec74f0 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { applyMongoGridChangesToDocument, buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts"; +import { applyMongoGridChangesToDocument, buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts"; test("parses Mongo shell ISODate literals as extended JSON dates", () => { assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), { @@ -18,6 +18,12 @@ test("parses legacy Mongo date display values as UTC dates", () => { assert.equal(parseMongoDocumentInputValue('"2025-08-14 02:25:43.718"'), "2025-08-14 02:25:43.718"); }); +test("preserves unsafe Mongo int64 input values without JavaScript rounding", () => { + assert.deepEqual(parseMongoDocumentInputValue("2048938405781032962"), { $numberLong: "2048938405781032962" }); + assert.equal(parseMongoDocumentInputValue("9007199254740991"), 9007199254740991); + assert.equal(parseMongoDocumentInputValue("9223372036854775808"), "9223372036854775808"); +}); + test("builds Mongo grid updates with set and unset operators", () => { const changes = new Map([ [1, "Ada"], @@ -218,6 +224,14 @@ test("formats extended JSON object ids as Mongo shell ObjectId literals", () => assert.equal(formatMongoShellLiteral({ $oid: "6743e4bfa3f6f84bc3fff6c8" }), 'ObjectId("6743e4bfa3f6f84bc3fff6c8")'); }); +test("serializes typed Mongo document ids while keeping their grid display compact", () => { + const id = { $numberLong: "2048938405781032962" }; + assert.equal(serializeMongoDocumentId(id), '{"$numberLong":"2048938405781032962"}'); + assert.equal(mongoDocumentIdForGrid(id), "2048938405781032962"); + assert.equal(serializeMongoDocumentId("2048938405781032962"), '__dbx_mongo_string_id__"2048938405781032962"'); + assert.equal(serializeMongoDocumentId('{"$numberLong":"2048938405781032962"}'), '__dbx_mongo_string_id__"{\\"$numberLong\\":\\"2048938405781032962\\"}"'); +}); + test("formats extended JSON int64 values as Mongo shell NumberLong literals", () => { assert.equal(formatMongoShellLiteral({ snowflake: { $numberLong: "9007199254740993" } }), '{"snowflake":NumberLong("9007199254740993")}'); }); diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 3c75787285..e44f845de1 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -65,6 +65,17 @@ test("parseMongoFindCommand accepts Compass-style unquoted keys and ObjectId", ( assert.deepEqual(JSON.parse(command.filter), { _id: { $oid: "6a045a92d2971e44243771a1" } }); }); +test("parseMongoFindCommand does not rewrite NumberLong text inside strings", () => { + const command = parseMongoFindCommand('db.orders.find({label: "NumberLong(123)"})'); + assert.deepEqual(command, { + collection: "orders", + filter: '{"label": "NumberLong(123)"}', + skip: 0, + limit: 100, + sort: undefined, + }); +}); + test("parseMongoFindCommand rewrites ISODate into extended JSON $date", () => { const command = parseMongoFindCommand(`db.trainingdocuments.find({ createdAt: { $gte: ISODate("2025-02-25T04:57:39.965Z") } @@ -82,6 +93,16 @@ test("parseMongoFindCommand rewrites new Date and single-quoted ISODate", () => }); }); +test("parseMongoFindCommand rewrites NumberLong into extended JSON", () => { + const quoted = parseMongoFindCommand('db.orders.find({_id: NumberLong("2048938405781032962")})'); + const unquoted = parseMongoFindCommand("db.orders.find({snowflake: NumberLong(9007199254740993)})"); + + assert.ok(quoted); + assert.deepEqual(JSON.parse(quoted.filter), { _id: { $numberLong: "2048938405781032962" } }); + assert.ok(unquoted); + assert.deepEqual(JSON.parse(unquoted.filter), { snowflake: { $numberLong: "9007199254740993" } }); +}); + 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); @@ -515,6 +536,14 @@ test("mongoDocumentsToQueryResult turns mongo documents into grid rows", () => { assert.equal(result.truncated, true); }); +test("mongoDocumentsToQueryResult displays typed int64 ids without losing raw type metadata", () => { + const id = { $numberLong: "2048938405781032962" }; + const result = mongoDocumentsToQueryResult([{ _id: id, name: "snowflake" }], 1, 1); + + assert.deepEqual(result.rows, [["2048938405781032962", "snowflake"]]); + assert.deepEqual(result.mongo_documents, [{ _id: id, name: "snowflake" }]); +}); + test("buildMongoUpdateDocument ignores _id and preserves typed values", () => { const changes = new Map([ [0, "other-id"],