From 5bcad3adc642853a8481b95e6181be13a7b2d0a9 Mon Sep 17 00:00:00 2001 From: onenewcode Date: Thu, 16 Jul 2026 18:21:01 +0800 Subject: [PATCH] feat(mongo): add whole-document JSON editing --- .../components/document/DocumentBrowser.vue | 261 ++++++++++-- apps/desktop/src/i18n/locales/en.ts | 8 + apps/desktop/src/i18n/locales/es.ts | 8 + apps/desktop/src/i18n/locales/it.ts | 8 + apps/desktop/src/i18n/locales/ja.ts | 8 + apps/desktop/src/i18n/locales/pt-BR.ts | 8 + apps/desktop/src/i18n/locales/zh-CN.ts | 8 + apps/desktop/src/i18n/locales/zh-TW.ts | 8 + .../desktop/src/lib/app/documentJsonValues.ts | 371 ++++++++++++++++++ apps/desktop/src/lib/app/documentStoreSave.ts | 96 +++++ apps/desktop/src/lib/backend/http.ts | 8 +- apps/desktop/src/lib/backend/tauri.ts | 8 +- .../dbx-core/src/db/elasticsearch_driver.rs | 58 ++- crates/dbx-core/src/document_ops.rs | 3 +- crates/dbx-core/src/mongo_ops.rs | 2 +- crates/dbx-web/src/routes/document_store.rs | 2 + crates/dbx-web/src/routes/mongo.rs | 1 + docs/content/docs/mongodb.cn.mdx | 13 +- docs/content/docs/mongodb.mdx | 13 +- packages/app-tests/documentBrowser.test.ts | 32 ++ packages/app-tests/documentJsonValues.test.ts | 223 ++++++++++- packages/app-tests/documentStoreSave.test.ts | 175 +++++++++ src-tauri/src/commands/document_cmd.rs | 11 +- src-tauri/src/commands/mongo_cmd.rs | 11 +- 24 files changed, 1277 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/src/lib/app/documentStoreSave.ts create mode 100644 packages/app-tests/documentStoreSave.test.ts diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 2e6674a290..bdd07e27c3 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -35,7 +35,19 @@ import { type ElasticsearchBoolClause, type ElasticsearchQueryType, } from "@/lib/app/documentStoreProvider"; -import { documentStoreValueForGrid, parseDocumentStoreInputValue, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues"; +import { + isDocumentStoreIdentityField, + normalizeDocumentStoreRouting, + parseDocumentStoreInputValue, + parseDocumentStoreJsonDocument, + planDocumentStoreIdentityMigration, + resolveDocumentStoreWriteRouting, + serializeDocumentStoreId, + stringifyDocumentStoreValue, + documentStoreValueForGrid, +} from "@/lib/app/documentJsonValues"; +import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave"; +import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue"; import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; @@ -75,6 +87,8 @@ const selectedIdx = ref(null); const editJson = ref(""); const isEditing = ref(false); const isNew = ref(false); +const documentEditMode = ref<"fields" | "json">("json"); +const isSavingDocument = ref(false); const error = ref(""); const editFields = ref([]); const showDeleteConfirm = ref(false); @@ -344,20 +358,21 @@ function documentIdFromGridValue(value: MongoInputValue | undefined): string | n return id.trim() ? id : null; } -function documentRoutingValue(value: unknown): string | undefined { - if (value === null || value === undefined) return undefined; - const routing = typeof value === "string" ? value : String(value); - const trimmed = routing.trim(); - return trimmed ? trimmed : undefined; -} - function documentRoutingFromDocument(doc: JsonRecord | undefined): string | undefined { - return documentRoutingValue(doc?._routing); + return normalizeDocumentStoreRouting(doc?._routing); } function documentRoutingFromGridRow(row: MongoInputValue[] | undefined, columns: string[]): string | undefined { const routingColIdx = columns.indexOf("_routing"); - return routingColIdx >= 0 ? documentRoutingValue(row?.[routingColIdx]) : undefined; + return routingColIdx >= 0 ? normalizeDocumentStoreRouting(row?.[routingColIdx]) : undefined; +} + +function documentStoreWriteApis() { + return { + insert: (docJson: string, routing?: string) => api.documentInsertDocument(props.connectionId, props.database, props.collection, docJson, routing), + update: (id: string, docJson: string, routing?: string) => api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, docJson, routing), + delete: (id: string, routing?: string) => api.documentDeleteDocument(props.connectionId, props.database, props.collection, id, routing), + }; } async function gridSave(changes: DocumentGridChanges) { @@ -409,10 +424,11 @@ async function gridSave(changes: DocumentGridChanges) { const doc = isEs ? buildElasticsearchInsertDocument(newRow, cols) : buildMongoInsertDocument(newRow, cols); if (isEs) { const id = documentIdFromGridValue(newRow[idColIdx]); + const routing = documentRoutingFromGridRow(newRow, cols); if (id) { - await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, "elasticsearch"), documentRoutingFromGridRow(newRow, cols)); + await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, "elasticsearch"), routing); } else { - await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, "elasticsearch")); + await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, "elasticsearch"), routing); } continue; } @@ -643,38 +659,121 @@ function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previ } } +function emptyDocumentJson(): string { + return stringifyDocumentStoreValue({}, documentStoreProvider.value.kind, 2); +} + +function documentEditErrorMessage(result: { error: "empty" | "invalid" | "not-object" | "unsupported-number" } | { error: "duplicate-key"; field: string }): string { + if (result.error === "not-object") return t("mongo.documentMustBeObject"); + if (result.error === "unsupported-number") return t("mongo.unsupportedJsonNumber"); + if (result.error === "duplicate-key") return t("mongo.duplicateJsonKey", { field: result.field }); + return t("mongo.invalidJson"); +} + +function buildEditFieldsFromDocument(doc: JsonRecord): EditNode[] { + return Object.entries(doc).map(([name, value]) => { + const isMetadata = isDocumentStoreIdentityField(documentStoreProvider.value.kind, name); + // Metadata field names stay fixed; values are editable so _id / routing rekey is possible. + return createEditNode(name, value, isMetadata, false); + }); +} + +function metadataFieldsFromDocument(doc: JsonRecord | undefined): JsonRecord { + const metadata: JsonRecord = {}; + if (!doc) return metadata; + if (Object.prototype.hasOwnProperty.call(doc, "_id")) metadata._id = doc._id; + if (documentStoreProvider.value.kind === "elasticsearch" && Object.prototype.hasOwnProperty.call(doc, "_routing")) { + metadata._routing = doc._routing; + } + return metadata; +} + +function currentDocumentMetadata(): JsonRecord { + if (selectedDoc.value) return metadataFieldsFromDocument(selectedDoc.value); + // New documents keep metadata that already exists in either editor mode. + if (documentEditMode.value === "json") { + const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind); + return parsed.ok ? metadataFieldsFromDocument(parsed.document) : {}; + } + const metadata: JsonRecord = {}; + for (const field of editFields.value) { + const name = field.keyName.trim(); + if (isDocumentStoreIdentityField(documentStoreProvider.value.kind, name)) { + metadata[name] = buildValueFromNode(field, name); + } + } + return metadata; +} + +function syncEditJsonFromFields() { + const doc = buildDocumentFromFields(); + // Field mode omits root metadata keys; restore them for JSON round-trips (new + existing). + Object.assign(doc, currentDocumentMetadata()); + editJson.value = stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind, 2); +} + +function setDocumentEditMode(mode: "fields" | "json") { + if (!isEditing.value || documentEditMode.value === mode) return; + error.value = ""; + if (mode === "json") { + try { + syncEditJsonFromFields(); + documentEditMode.value = "json"; + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : String(e); + } + return; + } + + const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind); + if (!parsed.ok) { + error.value = documentEditErrorMessage(parsed); + return; + } + editFields.value = buildEditFieldsFromDocument(parsed.document); + documentEditMode.value = "fields"; +} + function selectDoc(idx: number) { selectedIdx.value = idx; editJson.value = stringifyDocumentStoreValue(documents.value[idx], documentStoreProvider.value.kind, 2); isEditing.value = false; isNew.value = false; + documentEditMode.value = "fields"; editFields.value = []; + error.value = ""; } function startNew() { selectedIdx.value = null; - editJson.value = ""; + editJson.value = emptyDocumentJson(); editFields.value = [createEditNode("", "", false, false)]; + documentEditMode.value = "json"; isEditing.value = true; isNew.value = true; + error.value = ""; } function startEdit() { const doc = selectedDoc.value; if (!doc) return; - editFields.value = Object.entries(doc).map(([name, value]) => { - const readonlyMetadata = name === "_id" || (documentStoreProvider.value.kind === "elasticsearch" && name === "_routing"); - return createEditNode(name, value, readonlyMetadata, readonlyMetadata); - }); + // Issue #2952: open whole-document JSON editing by default (DBeaver-style), not field tree. + editJson.value = stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind, 2); + editFields.value = buildEditFieldsFromDocument(doc); + documentEditMode.value = "json"; isEditing.value = true; isNew.value = false; + error.value = ""; } function cancelEdit() { isEditing.value = false; + documentEditMode.value = "fields"; if (isNew.value) { isNew.value = false; editFields.value = []; + editJson.value = ""; + error.value = ""; return; } if (selectedDoc.value) { @@ -765,7 +864,7 @@ function buildObjectFromNodes(nodes: EditNode[], path: string): JsonRecord { for (const field of nodes) { const name = field.keyName.trim(); - if (!name || (!path && (name === "_id" || (documentStoreProvider.value.kind === "elasticsearch" && name === "_routing")))) continue; + if (!name || (!path && isDocumentStoreIdentityField(documentStoreProvider.value.kind, name))) continue; if (seen.has(name)) throw new Error(t("mongo.duplicateField", { field: name })); seen.add(name); doc[name] = buildValueFromNode(field, path ? `${path}.${name}` : name); @@ -786,23 +885,102 @@ function buildDocumentFromFields(): JsonRecord { return buildObjectFromNodes(editFields.value, ""); } -async function saveDoc() { - error.value = ""; +function buildDocumentFromEditor(): JsonRecord | null { + if (documentEditMode.value === "json") { + const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind); + if (!parsed.ok) { + error.value = documentEditErrorMessage(parsed); + return null; + } + return parsed.document; + } + + // Field mode skips root metadata in buildDocumentFromFields(); reattach identity field values. + const doc = buildDocumentFromFields(); + for (const field of editFields.value) { + const name = field.keyName.trim(); + if (isDocumentStoreIdentityField(documentStoreProvider.value.kind, name)) { + doc[name] = buildValueFromNode(field, name); + } + } + return doc; +} + +function resolveDocumentStorePathId(id: unknown): string | null { + if (documentStoreProvider.value.kind === "elasticsearch") { + return documentIdFromGridValue(documentStoreValueForGrid(id, "elasticsearch")); + } + if (id === undefined || id === null || id === "") return null; try { - const doc = buildDocumentFromFields(); + const serialized = serializeDocumentStoreId(id, documentStoreProvider.value.kind); + return serialized.trim() ? serialized : null; + } catch { + return null; + } +} + +function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, currentRouting: string | undefined): { writeId: string; writeRouting?: string } | null { + const kind = documentStoreProvider.value.kind; + const hasPayloadId = Object.prototype.hasOwnProperty.call(doc, "_id"); + const writeId = hasPayloadId ? resolveDocumentStorePathId(doc._id) : resolveDocumentStorePathId(currentId); + if (!writeId) return null; + const writeRouting = kind === "elasticsearch" ? resolveDocumentStoreWriteRouting(doc, currentRouting) : undefined; + return { writeId, writeRouting }; +} + +async function saveDoc() { + if (isSavingDocument.value) return; + error.value = ""; + isSavingDocument.value = true; + try { + const doc = buildDocumentFromEditor(); + if (!doc) return; + + const apis = documentStoreWriteApis(); + const kind = documentStoreProvider.value.kind; + if (isNew.value) { - await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind)); + const explicitId = kind === "elasticsearch" ? documentIdFromGridValue(documentStoreValueForGrid(doc._id, "elasticsearch")) : null; + await insertDocumentStoreDocumentCore({ + kind, + document: doc, + explicitId, + routing: normalizeDocumentStoreRouting(doc._routing), + apis, + }); } else if (selectedIdx.value !== null) { const current = documents.value[selectedIdx.value]; - const id = current?._id; - if (!id) { + const currentId = current?._id; + if (currentId === undefined || currentId === null) { error.value = "No _id field"; return; } - await api.documentUpdateDocument(props.connectionId, props.database, props.collection, serializeDocumentStoreId(id, documentStoreProvider.value.kind), stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind), documentRoutingFromDocument(current)); + + const deleteId = resolveDocumentStorePathId(currentId); + if (!deleteId) { + error.value = "No _id field"; + return; + } + const currentRouting = documentRoutingFromDocument(current); + const write = resolveWriteIdentityFromEditor(doc, currentId, currentRouting); + if (!write) { + error.value = t("mongo.jsonIdRequired"); + return; + } + + const plan = planDocumentStoreIdentityMigration({ + write: { id: write.writeId, routing: write.writeRouting }, + current: { id: deleteId, routing: kind === "elasticsearch" ? currentRouting : undefined }, + }); + // Rekey writes first then deletes; write failure leaves the old document intact. + await applyDocumentStoreIdentityPlan({ kind, plan, document: doc, apis }); + } else { + return; } + isEditing.value = false; isNew.value = false; + documentEditMode.value = "fields"; editFields.value = []; await load(); if (selectedIdx.value !== null && documents.value[selectedIdx.value]) { @@ -810,6 +988,8 @@ async function saveDoc() { } } catch (e: unknown) { error.value = e instanceof Error ? e.message : String(e); + } finally { + isSavingDocument.value = false; } } @@ -1292,21 +1472,34 @@ function resetTableSearchSplitWidth() { -
-
-
{
+
+ {{ t("mongo.jsonReplaceHint") }} +
- +
+
+ +
+
+
+
{
- + -
}
+ + +
}
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8a31047941..f91b1df3b0 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2487,8 +2487,16 @@ export default { selectDocument: "Select a document", readonlyId: "_id is read-only", invalidJsonValue: "This cell is not a valid JSON value", + invalidJson: "Invalid JSON", + documentMustBeObject: "Document JSON must be an object", + unsupportedJsonNumber: "JSON contains a number outside MongoDB int64 range", + duplicateJsonKey: "Duplicate JSON key: {field}", duplicateField: "Duplicate field name: {field}", edit: "Edit", + editModeFields: "Fields", + editModeJson: "JSON", + jsonReplaceHint: "JSON save replaces the whole document. Changing _id creates a new document and deletes the old one.", + jsonIdRequired: "A valid _id is required when changing document identity.", documentView: "Document View", tableView: "Table View", filterPlaceholder: "Filter...", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d6d011b776..9c7563c5ff 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2336,8 +2336,16 @@ export default withEnglishFallback({ selectDocument: "Selecciona un documento", readonlyId: "_id es de solo lectura", invalidJsonValue: "Esta celda no contiene un valor JSON válido", + invalidJson: "JSON no válido", + documentMustBeObject: "El JSON del documento debe ser un objeto", + unsupportedJsonNumber: "El JSON contiene un número fuera del rango int64 de MongoDB", + duplicateJsonKey: "Clave JSON duplicada: {field}", duplicateField: "Nombre de campo duplicado: {field}", edit: "Editar", + editModeFields: "Campos", + editModeJson: "JSON", + jsonReplaceHint: "Guardar en JSON reemplaza todo el documento. Cambiar _id crea un documento nuevo y elimina el anterior.", + jsonIdRequired: "Se requiere un _id válido al cambiar la identidad del documento.", documentView: "Vista de documento", tableView: "Vista de tabla", filterPlaceholder: "Filtro...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ab4e05aba9..6a1afedb0e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2334,8 +2334,16 @@ export default withEnglishFallback({ selectDocument: "Seleziona un documento", readonlyId: "_id è in sola lettura", invalidJsonValue: "Questa cella non contiene un valore JSON valido", + invalidJson: "JSON non valido", + documentMustBeObject: "Il JSON del documento deve essere un oggetto", + unsupportedJsonNumber: "Il JSON contiene un numero fuori dall'intervallo int64 di MongoDB", + duplicateJsonKey: "Chiave JSON duplicata: {field}", duplicateField: "Nome campo duplicato: {field}", edit: "Modifica", + editModeFields: "Campi", + editModeJson: "JSON", + jsonReplaceHint: "Il salvataggio JSON sostituisce l'intero documento. Cambiare _id crea un nuovo documento ed elimina quello vecchio.", + jsonIdRequired: "Serve un _id valido quando si modifica l'identità del documento.", documentView: "Visualizzazione Documento", tableView: "Visualizzazione Tabella", filterPlaceholder: "Filtra...", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d34f060f83..60ad5f8ef3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2335,8 +2335,16 @@ export default withEnglishFallback({ selectDocument: "ドキュメントを選択", readonlyId: "_idは読み取り専用です", invalidJsonValue: "このセルは有効なJSON値ではありません", + invalidJson: "無効なJSON", + documentMustBeObject: "ドキュメントJSONはオブジェクトである必要があります", + unsupportedJsonNumber: "JSON に MongoDB の int64 範囲外の数値が含まれています", + duplicateJsonKey: "JSON キーが重複しています: {field}", duplicateField: "重複するフィールド名: {field}", edit: "編集", + editModeFields: "フィールド", + editModeJson: "JSON", + jsonReplaceHint: "JSON 保存はドキュメント全体を置換します。_id を変更すると新しいドキュメントを作成し、古いドキュメントを削除します。", + jsonIdRequired: "ドキュメント識別子を変更する場合は有効な _id が必要です。", documentView: "ドキュメントビュー", tableView: "テーブルビュー", filterPlaceholder: "フィルター...", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ad1eef18f0..4298b10b94 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2336,8 +2336,16 @@ export default withEnglishFallback({ selectDocument: "Selecione um documento", readonlyId: "_id é somente leitura", invalidJsonValue: "Esta célula não é um valor JSON válido", + invalidJson: "JSON inválido", + documentMustBeObject: "O JSON do documento deve ser um objeto", + unsupportedJsonNumber: "O JSON contém um número fora do intervalo int64 do MongoDB", + duplicateJsonKey: "Chave JSON duplicada: {field}", duplicateField: "Nome de campo duplicado: {field}", edit: "Editar", + editModeFields: "Campos", + editModeJson: "JSON", + jsonReplaceHint: "Salvar em JSON substitui o documento inteiro. Alterar _id cria um novo documento e remove o antigo.", + jsonIdRequired: "É necessário um _id válido ao alterar a identidade do documento.", documentView: "Visão de documento", tableView: "Visão de tabela", filterPlaceholder: "Filtrar...", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9c5762d5f8..8b36dd7bc7 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2457,8 +2457,16 @@ export default withEnglishFallback({ selectDocument: "选择一个文档", readonlyId: "_id 只读", invalidJsonValue: "当前单元格不是合法 JSON 值", + invalidJson: "不是合法的 JSON", + documentMustBeObject: "文档 JSON 必须是对象", + unsupportedJsonNumber: "JSON 中包含超出 MongoDB int64 范围的数字", + duplicateJsonKey: "JSON 字段重复:{field}", duplicateField: "字段名重复:{field}", edit: "编辑", + editModeFields: "字段", + editModeJson: "JSON", + jsonReplaceHint: "JSON 保存会整体替换文档。修改 _id 会新建文档并删除旧文档。", + jsonIdRequired: "修改文档标识时必须提供有效的 _id。", documentView: "文档视图", tableView: "表格视图", filterPlaceholder: "过滤条件...", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1dc2ddd474..d5a5257b3c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2192,8 +2192,16 @@ export default withEnglishFallback({ selectDocument: "選擇一個文件", readonlyId: "_id 唯讀", invalidJsonValue: "目前儲存格不是合法 JSON 值", + invalidJson: "不是合法的 JSON", + documentMustBeObject: "文件 JSON 必須是物件", + unsupportedJsonNumber: "JSON 包含超出 MongoDB int64 範圍的數字", + duplicateJsonKey: "JSON 欄位重複:{field}", duplicateField: "欄位名重複:{field}", edit: "編輯", + editModeFields: "欄位", + editModeJson: "JSON", + jsonReplaceHint: "JSON 儲存會整份取代文件。修改 _id 會建立新文件並刪除舊文件。", + jsonIdRequired: "變更文件識別時必須提供有效的 _id。", documentView: "文件檢視", tableView: "表格檢視", filterPlaceholder: "過濾條件……", diff --git a/apps/desktop/src/lib/app/documentJsonValues.ts b/apps/desktop/src/lib/app/documentJsonValues.ts index 54dc47cffe..89033877dd 100644 --- a/apps/desktop/src/lib/app/documentJsonValues.ts +++ b/apps/desktop/src/lib/app/documentJsonValues.ts @@ -2,6 +2,19 @@ import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers, stringifyJsonPre import { parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import type { DocumentStoreKind } from "@/lib/app/documentStoreProvider"; +const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); +const MIN_BSON_INT64 = -9223372036854775808n; +const MAX_BSON_INT64 = 9223372036854775807n; + +export type ParseDocumentStoreJsonDocumentError = "empty" | "invalid" | "not-object" | "unsupported-number" | "duplicate-key"; + +export type ParseDocumentStoreJsonDocumentResult = { ok: true; document: Record } | { ok: false; error: Exclude } | { ok: false; error: "duplicate-key"; field: string }; + +export type PrepareDocumentStoreWriteDocumentOptions = { + kind: DocumentStoreKind; + mode: "insert" | "update"; +}; + export function parseDocumentStoreInputValue(raw: MongoInputValue, kind: DocumentStoreKind): unknown { if (kind === "mongodb") return parseMongoDocumentInputValue(raw); if (raw === null || typeof raw === "number" || typeof raw === "boolean") return raw; @@ -28,3 +41,361 @@ export function documentStoreValueForGrid(value: unknown, kind: DocumentStoreKin export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind): string { return kind === "elasticsearch" ? String(value) : serializeMongoDocumentId(value); } + +/** + * Parse a whole document JSON payload for MongoDB / Elasticsearch editors. + * Accepts standard JSON and Extended JSON objects; rejects non-object roots. + * Duplicate object keys are rejected instead of being silently collapsed by JSON.parse. + */ +export function parseDocumentStoreJsonDocument(text: string, kind: DocumentStoreKind): ParseDocumentStoreJsonDocumentResult { + const trimmed = text.trim(); + if (!trimmed) return { ok: false, error: "empty" }; + + const duplicateKey = findDuplicateJsonObjectKey(trimmed); + if (duplicateKey) return { ok: false, error: "duplicate-key", field: duplicateKey }; + + let parsed: unknown; + try { + parsed = parseJsonPreservingLargeNumbers(trimmed); + } catch { + return { ok: false, error: "invalid" }; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: "not-object" }; + } + + try { + const document = kind === "mongodb" ? (convertMongoJsonValue(parsed) as Record) : (parsed as Record); + return { ok: true, document }; + } catch (error) { + if (error instanceof UnsupportedMongoJsonNumberError) return { ok: false, error: "unsupported-number" }; + return { ok: false, error: "invalid" }; + } +} + +/** + * Normalize a parsed document for insert/update. + * - Updates drop `_id` from the body (identity is applied via the write path). + * - Elasticsearch always drops `_routing` from the body; routing is an API argument on insert/update/delete. + * - Identity changes are planned by the caller (write under the new id/routing, then delete the old document). + */ +export function prepareDocumentStoreWriteDocument(document: Record, options: PrepareDocumentStoreWriteDocumentOptions): Record { + const next: Record = { ...document }; + + if (options.mode === "update" && Object.prototype.hasOwnProperty.call(next, "_id")) { + delete next._id; + } + + if (options.kind === "elasticsearch" && Object.prototype.hasOwnProperty.call(next, "_routing")) { + delete next._routing; + } + + return next; +} + +export function documentStoreIdsEqual(left: unknown, right: unknown, kind: DocumentStoreKind): boolean { + if (left === right) return true; + if (left == null || right == null) return left == null && right == null; + try { + return serializeDocumentStoreId(left, kind) === serializeDocumentStoreId(right, kind); + } catch { + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } + } +} + +/** Root identity metadata field (`_id`, and Elasticsearch `_routing`). */ +export function isDocumentStoreIdentityField(kind: DocumentStoreKind, name: string): boolean { + if (name === "_id") return true; + return kind === "elasticsearch" && name === "_routing"; +} + +/** Normalize Elasticsearch custom routing for API write/delete arguments. */ +export function normalizeDocumentStoreRouting(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + const routing = typeof value === "string" ? value : String(value); + const trimmed = routing.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * Resolve write routing from an edited document payload. + * - Present `_routing` key uses the normalized value (empty/null clears routing). + * - Absent `_routing` key keeps the current document routing. + */ +export function resolveDocumentStoreWriteRouting(nextDocument: Record, currentRouting: string | undefined): string | undefined { + if (Object.prototype.hasOwnProperty.call(nextDocument, "_routing")) { + return normalizeDocumentStoreRouting(nextDocument._routing); + } + return currentRouting; +} + +export type DocumentStoreIdentityCoords = { + id: string; + routing?: string; +}; + +export type DocumentStoreIdentityPlan = + | { action: "replace"; writeId: string; writeRouting?: string } + | { + action: "rekey"; + writeId: string; + writeRouting?: string; + deleteId: string; + deleteRouting?: string; + }; + +/** True when two document identities (path id + optional ES routing) are equal. */ +export function documentStoreIdentityEquals(left: DocumentStoreIdentityCoords, right: DocumentStoreIdentityCoords): boolean { + return left.id === right.id && left.routing === right.routing; +} + +/** + * Plan how to save relative to the currently selected identity. + * Callers must resolve path ids and routing first; this only compares string coordinates. + * + * Elasticsearch treats custom routing as part of identity: routing-only changes rekey + * (write under the new routing, then delete under the old routing after a successful write). + */ +export function planDocumentStoreIdentityMigration(options: { write: DocumentStoreIdentityCoords; current: DocumentStoreIdentityCoords }): DocumentStoreIdentityPlan { + if (documentStoreIdentityEquals(options.write, options.current)) { + return { action: "replace", writeId: options.write.id, writeRouting: options.write.routing }; + } + return { + action: "rekey", + writeId: options.write.id, + writeRouting: options.write.routing, + deleteId: options.current.id, + deleteRouting: options.current.routing, + }; +} + +class UnsupportedMongoJsonNumberError extends Error { + constructor(raw: string) { + super(`Unsupported MongoDB numeric literal: ${raw}`); + this.name = "UnsupportedMongoJsonNumberError"; + } +} + +function convertMongoJsonValue(value: unknown): unknown { + if (isLosslessJsonNumber(value)) return convertMongoLosslessNumber(value.raw); + + if (Array.isArray(value)) return value.map((item) => convertMongoJsonValue(item)); + + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, convertMongoJsonValue(item)])); + } + + return value; +} + +function convertMongoLosslessNumber(raw: string): unknown { + const trimmed = raw.trim(); + if (/^-?\d+$/.test(trimmed)) { + try { + const integer = BigInt(trimmed); + if (integer > MAX_SAFE_BIGINT || integer < -MAX_SAFE_BIGINT) { + if (integer >= MIN_BSON_INT64 && integer <= MAX_BSON_INT64) return { $numberLong: trimmed }; + throw new UnsupportedMongoJsonNumberError(trimmed); + } + return Number(trimmed); + } catch (error) { + if (error instanceof UnsupportedMongoJsonNumberError) throw error; + throw new UnsupportedMongoJsonNumberError(trimmed); + } + } + + const numeric = Number(trimmed); + return Number.isFinite(numeric) ? numeric : trimmed; +} + +function findDuplicateJsonObjectKey(text: string): string | null { + try { + const scanner = new LightweightJsonScanner(text); + return scanJsonValueForDuplicateKeys(scanner); + } catch { + return null; + } +} + +function scanJsonValueForDuplicateKeys(scanner: LightweightJsonScanner): string | null { + scanner.skipWhitespace(); + const ch = scanner.peek(); + if (ch === "{") return scanJsonObjectForDuplicateKeys(scanner); + if (ch === "[") return scanJsonArrayForDuplicateKeys(scanner); + scanner.readPrimitive(); + return null; +} + +function scanJsonObjectForDuplicateKeys(scanner: LightweightJsonScanner): string | null { + scanner.expect("{"); + scanner.skipWhitespace(); + if (scanner.peek() === "}") { + scanner.position += 1; + return null; + } + + const seen = new Set(); + while (true) { + const key = scanner.readStringValue(); + if (seen.has(key)) return key; + seen.add(key); + scanner.expect(":"); + const nested = scanJsonValueForDuplicateKeys(scanner); + if (nested) return nested; + scanner.skipWhitespace(); + const next = scanner.peek(); + if (next === ",") { + scanner.position += 1; + continue; + } + if (next === "}") { + scanner.position += 1; + return null; + } + throw new SyntaxError(`Expected ',' or '}' at position ${scanner.position}`); + } +} + +function scanJsonArrayForDuplicateKeys(scanner: LightweightJsonScanner): string | null { + scanner.expect("["); + scanner.skipWhitespace(); + if (scanner.peek() === "]") { + scanner.position += 1; + return null; + } + while (true) { + const nested = scanJsonValueForDuplicateKeys(scanner); + if (nested) return nested; + scanner.skipWhitespace(); + const next = scanner.peek(); + if (next === ",") { + scanner.position += 1; + continue; + } + if (next === "]") { + scanner.position += 1; + return null; + } + throw new SyntaxError(`Expected ',' or ']' at position ${scanner.position}`); + } +} + +class LightweightJsonScanner { + readonly text: string; + position = 0; + + constructor(text: string) { + this.text = text; + } + + peek(): string | undefined { + return this.text[this.position]; + } + + skipWhitespace() { + while (this.position < this.text.length) { + const character = this.text[this.position]; + if (character === " " || character === "\t" || character === "\n" || character === "\r") { + this.position += 1; + continue; + } + break; + } + } + + expect(character: string) { + this.skipWhitespace(); + if (this.text[this.position] !== character) { + throw new SyntaxError(`Expected '${character}' at position ${this.position}`); + } + this.position += 1; + } + + readStringValue(): string { + this.skipWhitespace(); + if (this.text[this.position] !== '"') throw new SyntaxError(`Expected string at position ${this.position}`); + this.position += 1; + let result = ""; + let escaped = false; + while (this.position < this.text.length) { + const character = this.text[this.position]; + if (escaped) { + if (character === "u") { + const hex = this.text.slice(this.position + 1, this.position + 5); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new SyntaxError(`Invalid unicode escape at position ${this.position}`); + result += String.fromCharCode(parseInt(hex, 16)); + this.position += 5; + } else { + const map: Record = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" }; + if (!(character in map)) throw new SyntaxError(`Invalid escape sequence at position ${this.position}`); + result += map[character]; + this.position += 1; + } + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + this.position += 1; + continue; + } + if (character === '"') { + this.position += 1; + return result; + } + if (character.charCodeAt(0) < 0x20) throw new SyntaxError(`Invalid control character in string at position ${this.position}`); + result += character; + this.position += 1; + } + throw new SyntaxError(`Unterminated string at position ${this.position}`); + } + + readPrimitive() { + this.skipWhitespace(); + const ch = this.peek(); + if (ch === '"') { + this.readStringValue(); + return; + } + if (ch === "-" || (ch !== undefined && ch >= "0" && ch <= "9")) { + this.readNumberToken(); + return; + } + for (const keyword of ["true", "false", "null"] as const) { + if (this.text.startsWith(keyword, this.position)) { + this.position += keyword.length; + return; + } + } + throw new SyntaxError(`Unexpected token at position ${this.position}`); + } + + readNumberToken() { + const start = this.position; + if (this.text[this.position] === "-") this.position += 1; + if (this.text[this.position] === "0") this.position += 1; + else if (this.isDigit(this.text[this.position])) { + while (this.isDigit(this.text[this.position])) this.position += 1; + } else throw new SyntaxError(`Invalid number at position ${start}`); + if (this.text[this.position] === ".") { + this.position += 1; + if (!this.isDigit(this.text[this.position])) throw new SyntaxError(`Invalid number at position ${start}`); + while (this.isDigit(this.text[this.position])) this.position += 1; + } + if (this.text[this.position] === "e" || this.text[this.position] === "E") { + this.position += 1; + if (this.text[this.position] === "+" || this.text[this.position] === "-") this.position += 1; + if (!this.isDigit(this.text[this.position])) throw new SyntaxError(`Invalid number at position ${start}`); + while (this.isDigit(this.text[this.position])) this.position += 1; + } + } + + private isDigit(character: string | undefined): boolean { + return character !== undefined && character >= "0" && character <= "9"; + } +} diff --git a/apps/desktop/src/lib/app/documentStoreSave.ts b/apps/desktop/src/lib/app/documentStoreSave.ts new file mode 100644 index 0000000000..49bfda1325 --- /dev/null +++ b/apps/desktop/src/lib/app/documentStoreSave.ts @@ -0,0 +1,96 @@ +import type { DocumentStoreKind } from "@/lib/app/documentStoreProvider"; +import { prepareDocumentStoreWriteDocument, stringifyDocumentStoreValue, type DocumentStoreIdentityPlan } from "@/lib/app/documentJsonValues"; + +export type DocumentStoreWriteApis = { + insert: (docJson: string, routing?: string) => Promise; + update: (id: string, docJson: string, routing?: string) => Promise; + delete: (id: string, routing?: string) => Promise; +}; + +/** + * Write a document body under a known identity. + * - `put`: Elasticsearch index-by-id / Mongo update-by-id (identity via path, not body). + * - `insert`: Mongo insert (and ES auto-id when no explicit id); routing is always an API arg. + */ +export async function writeDocumentStoreDocument(options: { kind: DocumentStoreKind; op: "put" | "insert"; id?: string; routing?: string; document: Record; apis: Pick }): Promise { + const prepared = prepareDocumentStoreWriteDocument(options.document, { + kind: options.kind, + mode: options.op === "put" ? "update" : "insert", + }); + const body = stringifyDocumentStoreValue(prepared, options.kind); + + if (options.op === "put") { + if (!options.id) throw new Error("Document write requires an id"); + await options.apis.update(options.id, body, options.routing); + return; + } + + await options.apis.insert(body, options.routing); +} + +/** + * Apply an identity plan for an existing document save. + * Rekey always writes first, then deletes the old identity — a failed write never deletes. + * Plan coordinates are assumed distinct for rekey (same identity is always `replace`). + */ +export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentStoreKind; plan: DocumentStoreIdentityPlan; document: Record; apis: DocumentStoreWriteApis }): Promise { + const { kind, plan, document, apis } = options; + + if (plan.action === "replace") { + await writeDocumentStoreDocument({ + kind, + op: "put", + id: plan.writeId, + routing: plan.writeRouting, + document, + apis, + }); + return; + } + + // Rekey write: ES uses put under the new id/routing; Mongo inserts a new document then deletes the old id. + if (kind === "elasticsearch") { + await writeDocumentStoreDocument({ + kind, + op: "put", + id: plan.writeId, + routing: plan.writeRouting, + document, + apis, + }); + } else { + await writeDocumentStoreDocument({ + kind, + op: "insert", + document, + apis, + }); + } + + // Only reached after a successful write — preserves the old document when write fails. + await apis.delete(plan.deleteId, plan.deleteRouting); +} + +/** Insert a new document (optional explicit ES id uses put). */ +export async function insertDocumentStoreDocument(options: { kind: DocumentStoreKind; document: Record; explicitId?: string | null; routing?: string; apis: Pick }): Promise { + const { kind, document, explicitId, routing, apis } = options; + if (kind === "elasticsearch" && explicitId) { + await writeDocumentStoreDocument({ + kind, + op: "put", + id: explicitId, + routing, + document, + apis, + }); + return; + } + + await writeDocumentStoreDocument({ + kind, + op: "insert", + routing, + document, + apis, + }); +} diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 88f87ce965..90f3dc9234 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2143,12 +2143,12 @@ export async function mongoDropIndexes(connectionId: string, database: string, c return post("/api/mongo/drop-indexes", { connectionId, database, collection, indexesJson, single }); } -export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise { - return documentInsertDocument(connectionId, database, collection, docJson); +export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { + return documentInsertDocument(connectionId, database, collection, docJson, routing); } -export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise { - return post("/api/document-store/insert-document", { connectionId, database, collection, docJson }); +export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { + return post("/api/document-store/insert-document", { connectionId, database, collection, docJson, routing }); } export async function mongoInsertDocuments(connectionId: string, database: string, collection: string, docsJson: string): Promise<{ affected_rows: number }> { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 38351bff6f..1522f988f5 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1919,12 +1919,12 @@ export async function mongoDropIndexes(connectionId: string, database: string, c return invoke("mongo_drop_indexes", { connectionId, database, collection, indexesJson, single }); } -export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise { - return documentInsertDocument(connectionId, database, collection, docJson); +export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { + return documentInsertDocument(connectionId, database, collection, docJson, routing); } -export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise { - return invoke("document_insert_document", { connectionId, database, collection, docJson }); +export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise { + return invoke("document_insert_document", { connectionId, database, collection, docJson, routing }); } export async function mongoInsertDocuments(connectionId: string, database: string, collection: string, docsJson: string): Promise<{ affected_rows: number }> { diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs index e7b3446d61..35e17153e0 100644 --- a/crates/dbx-core/src/db/elasticsearch_driver.rs +++ b/crates/dbx-core/src/db/elasticsearch_driver.rs @@ -203,6 +203,16 @@ fn elasticsearch_query_value(value: &str) -> String { fn elasticsearch_document_path(index: &str, id: &str, routing: Option<&str>) -> String { let base = format!("/{}/_doc/{}", elasticsearch_path_segment(index), elasticsearch_path_segment(id)); + elasticsearch_path_with_routing_refresh(base, routing) +} + +/// Auto-id index path: `POST /{index}/_doc` with optional custom routing. +fn elasticsearch_auto_id_document_path(index: &str, routing: Option<&str>) -> String { + let base = format!("/{}/_doc", elasticsearch_path_segment(index)); + elasticsearch_path_with_routing_refresh(base, routing) +} + +fn elasticsearch_path_with_routing_refresh(base: String, routing: Option<&str>) -> String { if let Some(routing) = routing.map(str::trim).filter(|value| !value.is_empty()) { format!("{base}?routing={}&refresh=true", elasticsearch_query_value(routing)) } else { @@ -671,10 +681,16 @@ fn elasticsearch_sort_from_document_sort(sort: Option<&str>) -> Result Result { - let doc = elasticsearch_document_body_from_json(doc_json)?; +pub async fn insert_document( + client: &EsClient, + index: &str, + doc_json: &str, + routing: Option<&str>, +) -> Result { + // Prefer explicit routing arg; fall back to body metadata for backward compatibility. + let (doc, routing) = elasticsearch_document_body_and_routing_from_json(doc_json, routing)?; - let path = elasticsearch_index_path(index, "_doc?refresh=true"); + let path = elasticsearch_auto_id_document_path(index, routing.as_deref()); let resp = client.post(&path).json(&doc).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?; if !resp.status().is_success() { @@ -706,10 +722,6 @@ pub async fn update_document( Ok(1) } -fn elasticsearch_document_body_from_json(doc_json: &str) -> Result { - elasticsearch_document_body_and_routing_from_json(doc_json, None).map(|(doc, _)| doc) -} - fn elasticsearch_document_body_and_routing_from_json( doc_json: &str, routing: Option<&str>, @@ -1807,6 +1819,28 @@ mod tests { assert_eq!(super::elasticsearch_document_path("orders", "1", None), "/orders/_doc/1?refresh=true"); } + #[test] + fn builds_elasticsearch_auto_id_document_path_with_routing() { + assert_eq!( + super::elasticsearch_auto_id_document_path("orders/2026", Some("tenant/a&b")), + "/orders%2F2026/_doc?routing=tenant%2Fa%26b&refresh=true" + ); + assert_eq!(super::elasticsearch_auto_id_document_path("orders", None), "/orders/_doc?refresh=true"); + } + + #[test] + fn insert_document_body_extracts_routing_without_embedding_it() { + let (doc, routing) = + super::elasticsearch_document_body_and_routing_from_json(r#"{"_routing":"tenant-1","name":"Alice"}"#, None) + .expect("parse insert body"); + assert_eq!(routing.as_deref(), Some("tenant-1")); + assert_eq!(doc, serde_json::json!({"name":"Alice"})); + assert_eq!( + super::elasticsearch_auto_id_document_path("orders", routing.as_deref()), + "/orders/_doc?routing=tenant-1&refresh=true" + ); + } + #[test] fn elasticsearch_sql_detection_does_not_treat_rest_methods_as_sql() { assert!(super::is_elasticsearch_sql_query("SELECT * FROM index_task_v1")); @@ -2387,8 +2421,11 @@ mod tests { #[test] fn document_body_removes_elasticsearch_id_metadata() { - let doc = super::elasticsearch_document_body_from_json(r#"{"_id":"abc","_routing":"tenant-1","name":"Alice"}"#) - .unwrap(); + let (doc, _) = super::elasticsearch_document_body_and_routing_from_json( + r#"{"_id":"abc","_routing":"tenant-1","name":"Alice"}"#, + None, + ) + .unwrap(); assert_eq!(doc, json!({ "name": "Alice" })); } @@ -2419,7 +2456,8 @@ mod tests { #[test] fn document_body_preserves_user_field_order() { - let doc = super::elasticsearch_document_body_from_json(r#"{"z":1,"_id":"abc","a":2}"#).unwrap(); + let (doc, _) = + super::elasticsearch_document_body_and_routing_from_json(r#"{"z":1,"_id":"abc","a":2}"#, None).unwrap(); assert_eq!(serde_json::to_string(&doc).unwrap(), r#"{"z":1,"a":2}"#); } diff --git a/crates/dbx-core/src/document_ops.rs b/crates/dbx-core/src/document_ops.rs index 427ab5813d..cf23c30aa4 100644 --- a/crates/dbx-core/src/document_ops.rs +++ b/crates/dbx-core/src/document_ops.rs @@ -405,6 +405,7 @@ pub async fn insert_document_core( database: &str, collection: &str, doc_json: &str, + routing: Option<&str>, ) -> Result { ensure_document_pool(state, connection_id).await?; let connections = state.connections.read().await; @@ -413,7 +414,7 @@ pub async fn insert_document_core( PoolKind::Elasticsearch(client) => { let client = client.clone(); drop(connections); - elasticsearch_driver::insert_document(&client, collection, doc_json).await + elasticsearch_driver::insert_document(&client, collection, doc_json, routing).await } PoolKind::Agent(client) => { let mut client = client.lock().await; diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index f27f0cd9d6..f69f10bdd2 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -323,7 +323,7 @@ pub async fn mongo_insert_document_core( collection: &str, doc_json: &str, ) -> Result { - crate::document_ops::insert_document_core(state, connection_id, database, collection, doc_json).await + crate::document_ops::insert_document_core(state, connection_id, database, collection, doc_json, None).await } pub async fn mongo_insert_documents_core( diff --git a/crates/dbx-web/src/routes/document_store.rs b/crates/dbx-web/src/routes/document_store.rs index 45228e3ddf..a17063126f 100644 --- a/crates/dbx-web/src/routes/document_store.rs +++ b/crates/dbx-web/src/routes/document_store.rs @@ -76,6 +76,7 @@ pub struct DocumentInsertRequest { pub database: String, pub collection: String, pub doc_json: String, + pub routing: Option, } #[derive(Deserialize)] @@ -196,6 +197,7 @@ pub async fn insert_document( &req.database, &req.collection, &req.doc_json, + req.routing.as_deref(), ) .await .map_err(AppError)?; diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 709d65d25f..9929c827f3 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -481,6 +481,7 @@ pub async fn insert_document( &req.database, &req.collection, &req.doc_json, + None, ) .await .map_err(AppError)?; diff --git a/docs/content/docs/mongodb.cn.mdx b/docs/content/docs/mongodb.cn.mdx index b8aa6943d6..de840e861e 100644 --- a/docs/content/docs/mongodb.cn.mdx +++ b/docs/content/docs/mongodb.cn.mdx @@ -49,12 +49,15 @@ MongoDB 数据库和集合与关系型数据库一同显示在侧边栏中。浏 ## JSON 编辑器 -对于涉及嵌套字段的复杂编辑,可使用 JSON 编辑器: +在文档视图中,可在字段树编辑与整段 JSON 编辑之间切换: -- 以 JSON 格式编辑整个文档 -- 保存前进行语法验证 -- 适用于添加新的嵌套字段或重构文档结构 -- **日期保留**:编辑过程中正确保留日期值,维持其原始类型 +- 以完整 JSON 对象编辑或新建文档 +- 保存前对整段 JSON 做语法与结构校验 +- 新增文档时可直接粘贴整段 JSON +- 需要改单个字段时仍可使用字段编辑器 +- **更新时整文档替换**:JSON 保存会替换文档正文 +- **修改标识**:编辑 `_id` 会按新 id 新建文档,并删除旧文档 +- **日期与 Extended JSON 保留**:编辑过程中正确保留 `$date`、`$oid`、`$numberLong` 等值 ## MongoDB 版本 diff --git a/docs/content/docs/mongodb.mdx b/docs/content/docs/mongodb.mdx index 0518d924c5..2400883795 100644 --- a/docs/content/docs/mongodb.mdx +++ b/docs/content/docs/mongodb.mdx @@ -49,12 +49,15 @@ Edit documents directly from the table view: ## JSON Editor -For complex edits that span nested fields, use the JSON editor: +In document view, switch between field-tree editing and whole-document JSON editing: -- Edit the full document as JSON -- Syntax validation before saving -- Useful for adding new nested fields or restructuring documents -- **Date preservation**: Date values are preserved correctly during edits, maintaining their original type +- Edit or create a document as a full JSON object +- Validate the entire JSON payload before saving +- Paste complete documents when inserting new records +- Keep the field editor for targeted field changes +- **Whole-document replace on update**: saving JSON replaces the document body +- **Identity change**: editing `_id` inserts a document under the new id and deletes the previous one +- **Date and Extended JSON preservation**: `$date`, `$oid`, and `$numberLong` values are preserved correctly during edits ## MongoDB Versions diff --git a/packages/app-tests/documentBrowser.test.ts b/packages/app-tests/documentBrowser.test.ts index 05978eca24..8ef9ad875c 100644 --- a/packages/app-tests/documentBrowser.test.ts +++ b/packages/app-tests/documentBrowser.test.ts @@ -29,3 +29,35 @@ test("mongo document table passes copy context to the data grid", () => { assert.match(source, /mongo_copy_documents: copyDocuments\.value/); assert.match(source, /result\.extended_documents\?\.length === nextDocuments\.length/); }); + +test("document edit mode toggles whole JSON editing for insert and save", () => { + const source = documentBrowserSource(); + assert.match(source, /documentEditMode = ref<"fields" \| "json">\("json"\)/); + assert.match(source, /setDocumentEditMode\('json'\)/); + assert.match(source, /setDocumentEditMode\('fields'\)/); + assert.match(source, /function startEdit\(\)[\s\S]*?documentEditMode\.value = "json"/); + assert.match(source, /RedisJsonEditor v-model="editJson"/); + assert.match(source, /documentEditMode\.value = "json"/); + assert.match(source, /parseDocumentStoreJsonDocument\(editJson\.value, documentStoreProvider\.value\.kind\)/); + assert.match(source, /emptyDocumentJson\(\)/); + assert.match(source, /mongo\.jsonReplaceHint/); + assert.match(source, /isSavingDocument/); + assert.match(source, /unsupportedJsonNumber|unsupported-number/); + assert.match(source, /pointer-events-none/); + assert.match(source, /mongo\.jsonIdRequired/); + assert.match(source, /applyDocumentStoreIdentityPlan/); + assert.match(source, /planDocumentStoreIdentityMigration/); + assert.match(source, /insertDocumentStoreDocumentCore|insertDocumentStoreDocument/); +}); + +test("document save uses shared identity plan and write helpers", () => { + const source = documentBrowserSource(); + assert.match(source, /planDocumentStoreIdentityMigration\(/); + assert.match(source, /applyDocumentStoreIdentityPlan\(/); + assert.match(source, /resolveDocumentStoreWriteRouting\(/); + assert.match(source, /isDocumentStoreIdentityField\(/); + assert.match(source, /normalizeDocumentStoreRouting\(/); + // No local rekey/replace triple-copy orchestration. + assert.doesNotMatch(source, /async function rekeyDocumentStoreDocument/); + assert.doesNotMatch(source, /async function replaceDocumentStoreDocument/); +}); diff --git a/packages/app-tests/documentJsonValues.test.ts b/packages/app-tests/documentJsonValues.test.ts index 4b50575b0e..beb66d843e 100644 --- a/packages/app-tests/documentJsonValues.test.ts +++ b/packages/app-tests/documentJsonValues.test.ts @@ -1,6 +1,19 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { documentStoreValueForGrid, parseDocumentStoreInputValue, serializeDocumentStoreId, stringifyDocumentStoreValue } from "../../apps/desktop/src/lib/app/documentJsonValues.ts"; +import { + documentStoreIdentityEquals, + documentStoreValueForGrid, + isDocumentStoreIdentityField, + normalizeDocumentStoreRouting, + parseDocumentStoreInputValue, + parseDocumentStoreJsonDocument, + documentStoreIdsEqual, + planDocumentStoreIdentityMigration, + prepareDocumentStoreWriteDocument, + resolveDocumentStoreWriteRouting, + serializeDocumentStoreId, + stringifyDocumentStoreValue, +} from "../../apps/desktop/src/lib/app/documentJsonValues.ts"; test("keeps Elasticsearch long values as native numeric JSON without rounding", () => { const value = parseDocumentStoreInputValue("2018551659033767937", "elasticsearch"); @@ -31,3 +44,211 @@ test("does not reinterpret Mongo-compatible objects stored in Elasticsearch", () assert.deepEqual(value, { $numberLong: "2018551659033767937" }); assert.equal(stringifyDocumentStoreValue({ legacy: value }, "elasticsearch"), '{"legacy":{"$numberLong":"2018551659033767937"}}'); }); + +test("parses whole MongoDB document JSON with extended types and large ints", () => { + const parsed = parseDocumentStoreJsonDocument( + `{ + "_id": {"$oid": "6743e4bfa3f6f84bc3fff6c8"}, + "createdAt": {"$date": "2026-06-10T13:59:31.287Z"}, + "amount": 2018551659033767937, + "nested": {"score": 42} + }`, + "mongodb", + ); + + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.deepEqual(parsed.document, { + _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, + createdAt: { $date: "2026-06-10T13:59:31.287Z" }, + amount: { $numberLong: "2018551659033767937" }, + nested: { score: 42 }, + }); +}); + +test("parses whole Elasticsearch document JSON while preserving large numbers", () => { + const parsed = parseDocumentStoreJsonDocument('{"id":2018551659033767937,"name":"Ada"}', "elasticsearch"); + + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.equal(documentStoreValueForGrid(parsed.document.id, "elasticsearch"), "2018551659033767937"); + assert.equal(stringifyDocumentStoreValue(parsed.document, "elasticsearch"), '{"id":2018551659033767937,"name":"Ada"}'); +}); + +test("rejects invalid whole document JSON payloads", () => { + assert.deepEqual(parseDocumentStoreJsonDocument("", "mongodb"), { ok: false, error: "empty" }); + assert.deepEqual(parseDocumentStoreJsonDocument("{", "mongodb"), { ok: false, error: "invalid" }); + assert.deepEqual(parseDocumentStoreJsonDocument("[1,2]", "elasticsearch"), { ok: false, error: "not-object" }); + assert.deepEqual(parseDocumentStoreJsonDocument("null", "mongodb"), { ok: false, error: "not-object" }); + assert.deepEqual(parseDocumentStoreJsonDocument('{"amount":9223372036854775808}', "mongodb"), { ok: false, error: "unsupported-number" }); + assert.deepEqual( + parseDocumentStoreJsonDocument('{"name":"a","age":1,"name":"b"}', "mongodb"), + { ok: false, error: "duplicate-key", field: "name" }, + ); +}); + +test("prepareDocumentStoreWriteDocument always strips ES routing; update strips _id", () => { + const mongo = prepareDocumentStoreWriteDocument( + { + _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, + name: "Ada", + }, + { + kind: "mongodb", + mode: "update", + }, + ); + assert.deepEqual(mongo, { name: "Ada" }); + + const esUpdate = prepareDocumentStoreWriteDocument( + { + _id: "doc-1", + _routing: "shard-a", + title: "hello", + }, + { + kind: "elasticsearch", + mode: "update", + }, + ); + assert.deepEqual(esUpdate, { title: "hello" }); + + const insertKeepsId = prepareDocumentStoreWriteDocument( + { + _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, + name: "Ada", + }, + { + kind: "mongodb", + mode: "insert", + }, + ); + assert.deepEqual(insertKeepsId, { + _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, + name: "Ada", + }); + + // ES insert also strips _routing — routing is always an API argument. + const esInsert = prepareDocumentStoreWriteDocument( + { + _routing: "tenant-1", + title: "hello", + }, + { + kind: "elasticsearch", + mode: "insert", + }, + ); + assert.deepEqual(esInsert, { title: "hello" }); +}); + +test("documentStoreIdsEqual falls back safely for unstringifiable values", () => { + const circular: Record = {}; + circular.self = circular; + assert.equal(documentStoreIdsEqual(circular, circular, "mongodb"), true); + assert.doesNotThrow(() => documentStoreIdsEqual({ value: 1n }, { value: 2n }, "mongodb")); +}); + +test("isDocumentStoreIdentityField covers _id and ES routing only", () => { + assert.equal(isDocumentStoreIdentityField("mongodb", "_id"), true); + assert.equal(isDocumentStoreIdentityField("mongodb", "_routing"), false); + assert.equal(isDocumentStoreIdentityField("elasticsearch", "_id"), true); + assert.equal(isDocumentStoreIdentityField("elasticsearch", "_routing"), true); + assert.equal(isDocumentStoreIdentityField("elasticsearch", "title"), false); +}); + +test("normalizeDocumentStoreRouting trims empty values to undefined", () => { + assert.equal(normalizeDocumentStoreRouting(" tenant-a "), "tenant-a"); + assert.equal(normalizeDocumentStoreRouting(""), undefined); + assert.equal(normalizeDocumentStoreRouting(" "), undefined); + assert.equal(normalizeDocumentStoreRouting(null), undefined); + assert.equal(normalizeDocumentStoreRouting(undefined), undefined); + assert.equal(normalizeDocumentStoreRouting(42), "42"); +}); + +test("resolveDocumentStoreWriteRouting distinguishes omit from explicit clear", () => { + assert.equal(resolveDocumentStoreWriteRouting({ name: "Ada" }, "shard-a"), "shard-a"); + assert.equal(resolveDocumentStoreWriteRouting({ _routing: "shard-b", name: "Ada" }, "shard-a"), "shard-b"); + assert.equal(resolveDocumentStoreWriteRouting({ _routing: "", name: "Ada" }, "shard-a"), undefined); + assert.equal(resolveDocumentStoreWriteRouting({ _routing: null, name: "Ada" }, "shard-a"), undefined); + assert.equal(resolveDocumentStoreWriteRouting({ _routing: " ", name: "Ada" }, "shard-a"), undefined); +}); + +test("planDocumentStoreIdentityMigration covers owner review ES routing cases", () => { + // Same id + routing → replace body only. + assert.deepEqual( + planDocumentStoreIdentityMigration({ + write: { id: "doc-1", routing: "shard-a" }, + current: { id: "doc-1", routing: "shard-a" }, + }), + { action: "replace", writeId: "doc-1", writeRouting: "shard-a" }, + ); + + // Same _id, routing A→B: rekey write(B) delete(A). + assert.deepEqual( + planDocumentStoreIdentityMigration({ + write: { id: "doc-1", routing: "shard-b" }, + current: { id: "doc-1", routing: "shard-a" }, + }), + { + action: "rekey", + writeId: "doc-1", + writeRouting: "shard-b", + deleteId: "doc-1", + deleteRouting: "shard-a", + }, + ); + + // Same _id, clear routing. + assert.deepEqual( + planDocumentStoreIdentityMigration({ + write: { id: "doc-1", routing: undefined }, + current: { id: "doc-1", routing: "shard-a" }, + }), + { + action: "rekey", + writeId: "doc-1", + writeRouting: undefined, + deleteId: "doc-1", + deleteRouting: "shard-a", + }, + ); + + // Both _id and routing change. + assert.deepEqual( + planDocumentStoreIdentityMigration({ + write: { id: "doc-2", routing: "shard-b" }, + current: { id: "doc-1", routing: "shard-a" }, + }), + { + action: "rekey", + writeId: "doc-2", + writeRouting: "shard-b", + deleteId: "doc-1", + deleteRouting: "shard-a", + }, + ); + + // Id-only change, same routing. + assert.deepEqual( + planDocumentStoreIdentityMigration({ + write: { id: "doc-2", routing: "shard-a" }, + current: { id: "doc-1", routing: "shard-a" }, + }), + { + action: "rekey", + writeId: "doc-2", + writeRouting: "shard-a", + deleteId: "doc-1", + deleteRouting: "shard-a", + }, + ); + + // Same identity never produces rekey (no self-delete). + const same = planDocumentStoreIdentityMigration({ + write: { id: "doc-1", routing: "shard-a" }, + current: { id: "doc-1", routing: "shard-a" }, + }); + assert.equal(same.action, "replace"); + assert.equal(documentStoreIdentityEquals({ id: "doc-1", routing: "shard-a" }, { id: "doc-1", routing: "shard-a" }), true); +}); diff --git a/packages/app-tests/documentStoreSave.test.ts b/packages/app-tests/documentStoreSave.test.ts new file mode 100644 index 0000000000..92100d300d --- /dev/null +++ b/packages/app-tests/documentStoreSave.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument, writeDocumentStoreDocument } from "../../apps/desktop/src/lib/app/documentStoreSave.ts"; +import type { DocumentStoreWriteApis } from "../../apps/desktop/src/lib/app/documentStoreSave.ts"; + +function mockApis(overrides: Partial = {}): DocumentStoreWriteApis & { + calls: Array<{ op: string; args: unknown[] }>; +} { + const calls: Array<{ op: string; args: unknown[] }> = []; + return { + calls, + insert: async (...args) => { + calls.push({ op: "insert", args }); + return overrides.insert ? overrides.insert(...args) : "new-id"; + }, + update: async (...args) => { + calls.push({ op: "update", args }); + return overrides.update ? overrides.update(...args) : 1; + }, + delete: async (...args) => { + calls.push({ op: "delete", args }); + return overrides.delete ? overrides.delete(...args) : 1; + }, + }; +} + +test("ES replace puts body under current identity with routing arg", async () => { + const apis = mockApis(); + await applyDocumentStoreIdentityPlan({ + kind: "elasticsearch", + plan: { action: "replace", writeId: "doc-1", writeRouting: "shard-a" }, + document: { _id: "doc-1", _routing: "shard-a", title: "hello" }, + apis, + }); + assert.equal(apis.calls.length, 1); + assert.equal(apis.calls[0]?.op, "update"); + assert.equal(apis.calls[0]?.args[0], "doc-1"); + assert.equal(apis.calls[0]?.args[2], "shard-a"); + // Body must not embed identity metadata. + assert.equal(apis.calls[0]?.args[1], '{"title":"hello"}'); +}); + +test("ES same-id routing change rekeys: write new routing then delete old", async () => { + const apis = mockApis(); + await applyDocumentStoreIdentityPlan({ + kind: "elasticsearch", + plan: { + action: "rekey", + writeId: "doc-1", + writeRouting: "shard-b", + deleteId: "doc-1", + deleteRouting: "shard-a", + }, + document: { _id: "doc-1", _routing: "shard-b", title: "hello" }, + apis, + }); + assert.deepEqual( + apis.calls.map((call) => call.op), + ["update", "delete"], + ); + assert.deepEqual(apis.calls[0]?.args, ["doc-1", '{"title":"hello"}', "shard-b"]); + assert.deepEqual(apis.calls[1]?.args, ["doc-1", "shard-a"]); +}); + +test("ES id+routing change rekeys with both coordinates", async () => { + const apis = mockApis(); + await applyDocumentStoreIdentityPlan({ + kind: "elasticsearch", + plan: { + action: "rekey", + writeId: "doc-2", + writeRouting: "shard-b", + deleteId: "doc-1", + deleteRouting: "shard-a", + }, + document: { _id: "doc-2", _routing: "shard-b", title: "hello" }, + apis, + }); + assert.deepEqual(apis.calls[0]?.args, ["doc-2", '{"title":"hello"}', "shard-b"]); + assert.deepEqual(apis.calls[1]?.args, ["doc-1", "shard-a"]); +}); + +test("failed write does not delete the old document", async () => { + const apis = mockApis({ + update: async () => { + throw new Error("write failed"); + }, + }); + await assert.rejects( + () => + applyDocumentStoreIdentityPlan({ + kind: "elasticsearch", + plan: { + action: "rekey", + writeId: "doc-1", + writeRouting: "shard-b", + deleteId: "doc-1", + deleteRouting: "shard-a", + }, + document: { title: "hello" }, + apis, + }), + /write failed/, + ); + assert.deepEqual( + apis.calls.map((call) => call.op), + ["update"], + ); + assert.equal( + apis.calls.some((call) => call.op === "delete"), + false, + ); +}); + +test("Mongo rekey inserts then deletes without routing", async () => { + const apis = mockApis(); + await applyDocumentStoreIdentityPlan({ + kind: "mongodb", + plan: { + action: "rekey", + writeId: "new", + deleteId: "old", + }, + document: { _id: { $oid: "aaaaaaaaaaaaaaaaaaaaaaaa" }, name: "Ada" }, + apis, + }); + assert.deepEqual( + apis.calls.map((call) => call.op), + ["insert", "delete"], + ); + assert.match(String(apis.calls[0]?.args[0]), /aaaaaaaaaaaaaaaaaaaaaaaa/); + assert.equal(apis.calls[0]?.args[1], undefined); + assert.deepEqual(apis.calls[1]?.args, ["old", undefined]); +}); + +test("ES insert with routing passes routing API arg and strips body metadata", async () => { + const apis = mockApis(); + await insertDocumentStoreDocument({ + kind: "elasticsearch", + document: { _routing: "tenant-1", title: "hello" }, + routing: "tenant-1", + apis, + }); + assert.equal(apis.calls[0]?.op, "insert"); + assert.equal(apis.calls[0]?.args[0], '{"title":"hello"}'); + assert.equal(apis.calls[0]?.args[1], "tenant-1"); +}); + +test("ES insert with explicit id uses put + routing", async () => { + const apis = mockApis(); + await insertDocumentStoreDocument({ + kind: "elasticsearch", + document: { _id: "doc-1", _routing: "tenant-1", title: "hello" }, + explicitId: "doc-1", + routing: "tenant-1", + apis, + }); + assert.equal(apis.calls[0]?.op, "update"); + assert.deepEqual(apis.calls[0]?.args, ["doc-1", '{"title":"hello"}', "tenant-1"]); +}); + +test("writeDocumentStoreDocument put requires id", async () => { + const apis = mockApis(); + await assert.rejects( + () => + writeDocumentStoreDocument({ + kind: "elasticsearch", + op: "put", + document: { title: "x" }, + apis, + }), + /requires an id/, + ); + assert.equal(apis.calls.length, 0); +}); diff --git a/src-tauri/src/commands/document_cmd.rs b/src-tauri/src/commands/document_cmd.rs index 82930283df..3ff2d76c53 100644 --- a/src-tauri/src/commands/document_cmd.rs +++ b/src-tauri/src/commands/document_cmd.rs @@ -85,9 +85,18 @@ pub async fn document_insert_document( database: String, collection: String, doc_json: String, + routing: Option, ) -> Result { ensure_connection_writable(&state, &connection_id, "Insert").await?; - dbx_core::document_ops::insert_document_core(&state, &connection_id, &database, &collection, &doc_json).await + dbx_core::document_ops::insert_document_core( + &state, + &connection_id, + &database, + &collection, + &doc_json, + routing.as_deref(), + ) + .await } #[tauri::command] diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index df3e3c10fc..51855b4cf8 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -271,8 +271,17 @@ pub async fn mongo_insert_document( database: String, collection: String, doc_json: String, + routing: Option, ) -> Result { - crate::commands::document_cmd::document_insert_document(state, connection_id, database, collection, doc_json).await + crate::commands::document_cmd::document_insert_document( + state, + connection_id, + database, + collection, + doc_json, + routing, + ) + .await } #[tauri::command]