diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index b16b36d54..776de3e13 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -305,7 +305,7 @@ async function gridSave(changes: DocumentGridChanges) { continue; } - const updateDoc = buildMongoUpdateDocument(dirtyCols, cols); + 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)); } @@ -382,7 +382,7 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise(() = const row = changes.rows[rowIdx]; const id = row?.[idColIdx]; if (id === null || id === undefined || String(id).trim() === "") continue; - const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns); + 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)); } @@ -405,7 +405,7 @@ const mongoQueryResultSaveHandler = computed(() = const row = changes.rows[rowIdx]; const id = row?.[idColIdx]; if (id === null || id === undefined || String(id).trim() === "") continue; - const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns); + 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)})`); } diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index 2957e62f1..ce21e7b04 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -31,6 +31,20 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown { return raw; } +function parseMongoExistingFieldInputValue(raw: Exclude, originalValue: unknown): unknown { + // Objects and arrays are serialized into grid text too, so the raw document + // is the only reliable way to distinguish them from JSON-shaped BSON strings. + if (typeof originalValue === "string") { + return typeof raw === "string" ? raw : String(raw); + } + return parseMongoDocumentInputValue(raw); +} + +function mongoDocumentFieldValue(document: unknown, field: string): unknown { + if (!document || typeof document !== "object" || Array.isArray(document)) return undefined; + return (document as Record)[field]; +} + function legacyMongoDateDisplayToExtendedJson(value: string): { $date: string } | null { const match = value.match(LEGACY_MONGO_DATE_DISPLAY_PATTERN); if (!match) return null; @@ -38,7 +52,7 @@ function legacyMongoDateDisplayToExtendedJson(value: string): { $date: string } return { $date: `${date}T${time}.${millis.padEnd(3, "0")}Z` }; } -export function buildMongoUpdateDocument(changes: Map, columns: string[]): Record { +export function buildMongoUpdateDocument(changes: Map, columns: string[], originalDocument?: unknown): Record { const setFields: Record = {}; const unsetFields: Record = {}; for (const [colIdx, newVal] of changes) { @@ -47,7 +61,7 @@ export function buildMongoUpdateDocument(changes: Map, if (newVal === null) { unsetFields[col] = ""; } else { - setFields[col] = parseMongoDocumentInputValue(newVal); + setFields[col] = parseMongoExistingFieldInputValue(newVal, mongoDocumentFieldValue(originalDocument, col)); } } const doc: Record = {}; @@ -66,7 +80,7 @@ export function applyMongoGridChangesToDocument(document: unknown, changes: Map< if (newVal === null) { delete updated[column]; } else { - updated[column] = parseMongoDocumentInputValue(newVal); + updated[column] = parseMongoExistingFieldInputValue(newVal, updated[column]); } } return updated; diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index bb399e2ae..52ac421c4 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -36,6 +36,70 @@ test("builds Mongo grid updates with set and unset operators", () => { }); }); +test("preserves JSON-shaped strings when updating existing Mongo fields", () => { + const original = { + _id: "1", + answer: '{"action":"New","values":[1]}', + tagsText: '["draft"]', + profile: { role: "admin" }, + }; + const changes = new Map([ + [1, '{\n "action": "Updated",\n "values": [\n 1,\n 2\n ]\n}'], + [2, '[\n "published"\n]'], + [3, '{"role":"maintainer"}'], + ]); + + const update = buildMongoUpdateDocument(changes, ["_id", "answer", "tagsText", "profile"], original); + + assert.deepEqual(update, { + $set: { + answer: '{\n "action": "Updated",\n "values": [\n 1,\n 2\n ]\n}', + tagsText: '[\n "published"\n]', + profile: { role: "maintainer" }, + }, + }); + assert.equal(formatMongoShellLiteral(update), '{"$set":{"answer":"{\\n \\"action\\": \\"Updated\\",\\n \\"values\\": [\\n 1,\\n 2\\n ]\\n}","tagsText":"[\\n \\"published\\"\\n]","profile":{"role":"maintainer"}}}'); +}); + +test("preserves existing Mongo strings that resemble typed literals", () => { + const original = { + _id: "1", + numericText: "42", + booleanText: "true", + dateText: 'ISODate("2026-01-01T00:00:00.000Z")', + quotedText: '"literal"', + }; + const changes = new Map([ + [1, "43"], + [2, "false"], + [3, 'ISODate("2026-02-01T00:00:00.000Z")'], + [4, '"changed"'], + ]); + + assert.deepEqual(buildMongoUpdateDocument(changes, ["_id", "numericText", "booleanText", "dateText", "quotedText"], original), { + $set: { + numericText: "43", + booleanText: "false", + dateText: 'ISODate("2026-02-01T00:00:00.000Z")', + quotedText: '"changed"', + }, + }); +}); + +test("keeps JSON inference for fields without an existing Mongo type", () => { + const changes = new Map([ + [1, '{"enabled":true}'], + [2, "42"], + ]); + + assert.deepEqual(buildMongoUpdateDocument(changes, ["_id", "newObject", "newNumber"], { _id: "1" }), { + $set: { + newObject: { enabled: true }, + newNumber: 42, + }, + }); +}); + test("applies saved Mongo grid changes to the raw preview document", () => { const original = { _id: "1", @@ -62,6 +126,24 @@ test("applies saved Mongo grid changes to the raw preview document", () => { }); }); +test("applies Mongo grid edits without converting existing JSON strings", () => { + const original = { + _id: "1", + answer: '{"action":"New"}', + profile: { role: "admin" }, + }; + const changes = new Map([ + [1, '{\n "action": "Updated"\n}'], + [2, '{"role":"maintainer"}'], + ]); + + assert.deepEqual(applyMongoGridChangesToDocument(original, changes, ["_id", "answer", "profile"]), { + _id: "1", + answer: '{\n "action": "Updated"\n}', + profile: { role: "maintainer" }, + }); +}); + test("builds Mongo inserts with parsed date values", () => { assert.deepEqual(buildMongoInsertDocument(["ignored", 'new Date("2026-06-10T13:59:31.287Z")'], ["_id", "createdAt"]), { createdAt: { $date: "2026-06-10T13:59:31.287Z" },