diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index c49fe1821f..a9149a5535 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -16,6 +16,7 @@ import * as api from "@/lib/api"; import { clampSearchSplitWidth } from "@/lib/dataGridSearchSplit"; import { documentViewerFontStyle } from "@/lib/documentViewerFontStyle"; import { buildDocumentFilterCondition, combineDocumentFilterConditions, currentDocumentFilterJson, defaultDocumentFilterRule, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/documentStoreProvider"; +import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue, type MongoInputValue } from "@/lib/mongoDocumentValues"; import { normalizeResultPageSize } from "@/lib/paginationPageSize"; import { useSettingsStore } from "@/stores/settingsStore"; import JsonEditNode from "./JsonEditNode.vue"; @@ -226,34 +227,37 @@ function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) applyFilter(); } -async function gridSave(changes: { dirtyRows: Map>; deletedRows: Set; columns: string[]; rows: (string | number | boolean | null)[][] }) { +async function gridSave(changes: { dirtyRows: Map>; deletedRows: Set; columns: string[]; rows: MongoInputValue[][] }) { const cols = changes.columns; const idColIdx = cols.indexOf("_id"); if (idColIdx < 0) throw new Error("No _id column"); + const isEs = documentStoreProvider.value.kind === "elasticsearch"; for (const [rowIdx, dirtyCols] of changes.dirtyRows) { const row = changes.rows[rowIdx]; const id = row?.[idColIdx]; if (id == null) continue; - const doc = documents.value[rowIdx]; - if (!doc) continue; - const updated = { ...doc }; - for (const [colIdx, newVal] of dirtyCols) { - const col = cols[colIdx]; - if (col === "_id") continue; - if (newVal === null) { - delete updated[col]; - } else if (typeof newVal === "string") { - try { - updated[col] = JSON.parse(newVal); - } catch { - updated[col] = newVal; + + if (isEs) { + const doc = documents.value[rowIdx]; + if (!doc) continue; + const updated = { ...doc }; + for (const [colIdx, newVal] of dirtyCols) { + const col = cols[colIdx]; + if (col === "_id") continue; + if (newVal === null) { + delete updated[col]; + } else { + updated[col] = parseMongoDocumentInputValue(newVal); } - } else { - updated[col] = newVal; } + await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(updated)); + continue; } - await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(updated)); + + const updateDoc = buildMongoUpdateDocument(dirtyCols, cols); + if (Object.keys(updateDoc).length === 0) continue; + await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(updateDoc)); } for (const rowIdx of changes.deletedRows) { @@ -279,51 +283,7 @@ function mongoIdPreview(val: unknown): string { return formatMongoValue(val); } -function buildUpdateDoc(changes: Map, columns: string[]): Record { - const setFields: Record = {}; - const unsetFields: Record = {}; - for (const [colIdx, newVal] of changes) { - const col = columns[colIdx]; - if (!col || col === "_id") continue; - if (newVal === null) { - unsetFields[col] = ""; - } else if (typeof newVal === "string") { - try { - setFields[col] = JSON.parse(newVal); - } catch { - setFields[col] = newVal; - } - } else { - setFields[col] = newVal; - } - } - const doc: Record = {}; - if (Object.keys(setFields).length > 0) doc.$set = setFields; - if (Object.keys(unsetFields).length > 0) doc.$unset = unsetFields; - return doc; -} - -function buildNewDoc(newRow: (string | number | boolean | null)[], columns: string[]): Record { - const doc: Record = {}; - for (let ci = 0; ci < columns.length; ci++) { - const col = columns[ci]; - if (!col || col === "_id") continue; - const val = newRow[ci]; - if (val === null) continue; - if (typeof val === "string") { - try { - doc[col] = JSON.parse(val); - } catch { - doc[col] = val; - } - } else { - doc[col] = val; - } - } - return doc; -} - -async function previewDocumentChanges(changes: { dirtyRows: Map>; deletedRows: Set; newRows: (string | number | boolean | null)[][]; columns: string[]; rows: (string | number | boolean | null)[][] }): Promise { +async function previewDocumentChanges(changes: { dirtyRows: Map>; deletedRows: Set; newRows: MongoInputValue[][]; columns: string[]; rows: MongoInputValue[][] }): Promise { const { dirtyRows, deletedRows, newRows, columns, rows } = changes; const idColIdx = columns.indexOf("_id"); const stmts: string[] = []; @@ -334,11 +294,12 @@ async function previewDocumentChanges(changes: { dirtyRows: Map, columns: string[]): Record { + const setFields: Record = {}; + const unsetFields: Record = {}; + for (const [colIdx, newVal] of changes) { + const col = columns[colIdx]; + if (!col || col === "_id") continue; + if (newVal === null) { + unsetFields[col] = ""; + } else { + setFields[col] = parseMongoDocumentInputValue(newVal); + } + } + const doc: Record = {}; + if (Object.keys(setFields).length > 0) doc.$set = setFields; + if (Object.keys(unsetFields).length > 0) doc.$unset = unsetFields; + return doc; +} + +export function buildMongoInsertDocument(row: MongoInputValue[], columns: string[]): Record { + const doc: Record = {}; + for (let ci = 0; ci < columns.length; ci++) { + const col = columns[ci]; + if (!col || col === "_id") continue; + const val = row[ci]; + if (val === null) continue; + doc[col] = parseMongoDocumentInputValue(val); + } + return doc; +} + +export function formatMongoShellLiteral(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(formatMongoShellLiteral).join(",")}]`; + if (typeof value === "object") { + const object = value as Record; + const keys = Object.keys(object); + if (keys.length === 1 && typeof object.$date === "string") { + return `ISODate(${JSON.stringify(object.$date)})`; + } + return `{${keys.map((key) => `${JSON.stringify(key)}:${formatMongoShellLiteral(object[key])}`).join(",")}}`; + } + return JSON.stringify(String(value)); +} diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 1e0be91682..0cfb950352 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -267,6 +267,17 @@ pub async fn update_document( ) -> Result { let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?; let col = client.database(database).collection::(collection); + let update_doc = json_object_to_document(&value).map_err(|e| format!("Invalid document: {e}"))?; + if is_update_operator_document(&update_doc) { + for filter in document_id_filters(id) { + let result = col.update_one(filter, update_doc.clone()).await.map_err(|e| e.to_string())?; + if result.matched_count > 0 { + return Ok(result.modified_count); + } + } + return Ok(0); + } + for filter in document_id_filters(id) { let current = col.find_one(filter.clone()).await.map_err(|e| e.to_string())?; let mut new_doc = json_object_to_document_preserving_existing(&value, current.as_ref()) @@ -280,6 +291,10 @@ pub async fn update_document( Ok(0) } +fn is_update_operator_document(doc: &Document) -> bool { + !doc.is_empty() && doc.keys().all(|key| key.starts_with('$')) +} + pub async fn update_documents( client: &Client, database: &str, @@ -447,7 +462,9 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson { Bson::Null } } - serde_json::Value::String(s) => Bson::String(s.clone()), + serde_json::Value::String(s) => { + parse_mongo_shell_date(s).map(Bson::DateTime).unwrap_or_else(|| Bson::String(s.clone())) + } serde_json::Value::Array(arr) => Bson::Array(arr.iter().map(json_value_to_bson).collect()), serde_json::Value::Object(obj) => { // Extended JSON: {"$oid":"..."} → BSON ObjectId @@ -467,6 +484,48 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson { } } +fn parse_mongo_shell_date(value: &str) -> Option { + let trimmed = value.trim(); + if let Some(inner) = trimmed.strip_prefix("ISODate(").or_else(|| trimmed.strip_prefix("new Date(")) { + let inner = inner.strip_suffix(')')?.trim(); + let quoted = inner + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| inner.strip_prefix('\'').and_then(|value| value.strip_suffix('\'')))?; + return DateTime::parse_rfc3339_str(quoted).ok(); + } + parse_legacy_mongo_date_display(trimmed) +} + +fn parse_legacy_mongo_date_display(value: &str) -> Option { + let (date, time) = value.split_once(' ').or_else(|| value.split_once('T'))?; + if date.len() != 10 || time.len() < 8 || time.len() > 12 { + return None; + } + if !date + .chars() + .enumerate() + .all(|(index, ch)| matches!(index, 4 | 7) && ch == '-' || !matches!(index, 4 | 7) && ch.is_ascii_digit()) + { + return None; + } + let (seconds, millis) = time.split_once('.').unwrap_or((time, "000")); + if seconds.len() != 8 || millis.is_empty() || millis.len() > 3 { + return None; + } + if !seconds + .chars() + .enumerate() + .all(|(index, ch)| matches!(index, 2 | 5) && ch == ':' || !matches!(index, 2 | 5) && ch.is_ascii_digit()) + { + return None; + } + if !millis.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + DateTime::parse_rfc3339_str(format!("{date}T{seconds}.{}Z", format!("{millis:0<3}"))).ok() +} + fn parse_extended_json_date(obj: &serde_json::Map) -> Option { match obj.get("$date")? { serde_json::Value::String(value) => DateTime::parse_rfc3339_str(value).ok(), @@ -709,6 +768,39 @@ mod tests { )); } + #[test] + fn json_object_to_document_parses_mongo_shell_isodate_strings() { + let value = serde_json::json!({ + "created_at": "ISODate(\"2026-06-10T13:59:31.287Z\")", + "updated_at": "new Date('2026-06-10T14:59:31.287Z')", + }); + let doc = json_object_to_document(&value).unwrap(); + + assert!(matches!(doc.get("created_at"), Some(Bson::DateTime(_)))); + assert!(matches!(doc.get("updated_at"), Some(Bson::DateTime(_)))); + } + + #[test] + fn json_object_to_document_parses_legacy_date_display_strings() { + let value = serde_json::json!({ + "created_at": "2025-08-14 02:25:43.718", + }); + let doc = json_object_to_document(&value).unwrap(); + + assert!(matches!( + doc.get("created_at"), + Some(Bson::DateTime(value)) if value.timestamp_millis() == 1_755_138_343_718 + )); + } + + #[test] + fn detects_update_operator_documents() { + assert!(is_update_operator_document(&doc! { "$set": { "name": "Ada" } })); + assert!(is_update_operator_document(&doc! { "$set": { "name": "Ada" }, "$unset": { "old": "" } })); + assert!(!is_update_operator_document(&doc! { "name": "Ada" })); + assert!(!is_update_operator_document(&Document::new())); + } + #[test] fn json_object_to_document_preserves_unchanged_bson_date_fields() { let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap(); diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts new file mode 100644 index 0000000000..df2ac51970 --- /dev/null +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, parseMongoDocumentInputValue } from "../../apps/desktop/src/lib/mongoDocumentValues.ts"; + +test("parses Mongo shell ISODate literals as extended JSON dates", () => { + assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), { + $date: "2026-06-10T13:59:31.287Z", + }); + assert.deepEqual(parseMongoDocumentInputValue('"ISODate(\\"2026-06-10T13:59:31.287Z\\")"'), { + $date: "2026-06-10T13:59:31.287Z", + }); +}); + +test("parses legacy Mongo date display values as UTC dates", () => { + assert.deepEqual(parseMongoDocumentInputValue("2025-08-14 02:25:43.718"), { + $date: "2025-08-14T02:25:43.718Z", + }); + assert.equal(parseMongoDocumentInputValue('"2025-08-14 02:25:43.718"'), "2025-08-14 02:25:43.718"); +}); + +test("builds Mongo grid updates with set and unset operators", () => { + const changes = new Map([ + [1, "Ada"], + [2, 'ISODate("2026-06-10T13:59:31.287Z")'], + [3, null], + ]); + + assert.deepEqual(buildMongoUpdateDocument(changes, ["_id", "name", "createdAt", "archivedAt"]), { + $set: { + name: "Ada", + createdAt: { $date: "2026-06-10T13:59:31.287Z" }, + }, + $unset: { + archivedAt: "", + }, + }); +}); + +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" }, + }); +}); + +test("formats extended JSON dates as Mongo shell ISODate literals", () => { + assert.equal( + formatMongoShellLiteral({ + $set: { + createdAt: { $date: "2026-06-10T13:59:31.287Z" }, + }, + }), + '{"$set":{"createdAt":ISODate("2026-06-10T13:59:31.287Z")}}', + ); +});