mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:20:02 +00:00
fix(mongodb): preserve long ids in filters and updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user