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

This commit is contained in:
zipg
2026-07-14 11:09:11 +08:00
committed by GitHub
parent 2666ed1cd2
commit 2a5e7ac1c7
9 changed files with 325 additions and 31 deletions
@@ -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<String, Object> bsonToJson(Document doc) {
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> 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();
}
@@ -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(
@@ -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<QueryResult>(() => {
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<str
stmts.push(`POST /${coll}/_update/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}\n${JSON.stringify({ doc: updateDoc.$set ?? updateDoc }, null, 2)}`);
} else {
const updateDoc = buildMongoUpdateDocument(dirtyCols, columns, documents.value[rowIdx]);
stmts.push(`db.${coll}.updateOne({_id: ${mongoIdPreview(id)}}, ${formatMongoShellLiteral(updateDoc)})`);
stmts.push(`db.${coll}.updateOne({_id: ${mongoIdPreview(documents.value[rowIdx]?._id ?? id)}}, ${formatMongoShellLiteral(updateDoc)})`);
}
}
@@ -395,7 +406,7 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const routing = documentRoutingFromGridRow(row, columns);
stmts.push(`DELETE /${coll}/_doc/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}`);
} else {
stmts.push(`db.${coll}.deleteOne({_id: ${mongoIdPreview(id)}})`);
stmts.push(`db.${coll}.deleteOne({_id: ${mongoIdPreview(documents.value[rowIdx]?._id ?? id)}})`);
}
}
@@ -692,7 +703,7 @@ async function saveDoc() {
error.value = "No _id field";
return;
}
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(doc), documentRoutingFromDocument(current));
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, serializeMongoDocumentId(id), JSON.stringify(doc), documentRoutingFromDocument(current));
}
isEditing.value = false;
isNew.value = false;
@@ -712,7 +723,7 @@ async function applyDeleteDoc(idx: number) {
if (!id) return;
error.value = "";
try {
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, String(id), documentRoutingFromDocument(doc));
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, serializeMongoDocumentId(id), documentRoutingFromDocument(doc));
if (selectedIdx.value === idx) {
selectedIdx.value = null;
editJson.value = "";
@@ -75,7 +75,7 @@ import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
import * as api from "@/lib/backend/api";
import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
@@ -380,6 +380,11 @@ function mongoIdPreview(val: unknown): string {
function mongoCollectionExpression(collection: string): string {
return `db.getCollection(${JSON.stringify(collection)})`;
}
function mongoQueryResultDocumentId(rowIdx: number, fallback: unknown): unknown {
const document = props.activeTab.result?.mongo_documents?.[rowIdx];
if (!document || typeof document !== "object" || Array.isArray(document)) return fallback;
return (document as Record<string, unknown>)._id ?? fallback;
}
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
const tab = props.activeTab;
const target = tab.mongoEditTarget;
@@ -398,7 +403,7 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
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<CustomSaveHandler | undefined>(() =
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;
};
@@ -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);
}
+88 -3
View File
@@ -991,6 +991,12 @@ pub async fn delete_document(client: &Client, database: &str, collection: &str,
}
fn document_id_filters(id: &str) -> Vec<Document> {
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<Document> {
}
}
fn decode_string_document_id(id: &str) -> Option<String> {
id.strip_prefix("__dbx_mongo_string_id__").and_then(|payload| serde_json::from_str::<String>(payload).ok())
}
fn extended_json_document_id_filter(id: &str) -> Option<Document> {
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<Document, String> {
@@ -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<String, serde_json::Value>) ->
}
}
fn parse_extended_json_int64(obj: &serde_json::Map<String, serde_json::Value>) -> Option<i64> {
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));
+15 -1
View File
@@ -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<number, string | number | boolean | null>([
[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")}');
});
+29
View File
@@ -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<number, string | number | boolean | null>([
[0, "other-id"],