feat(mongo): add whole-document JSON editing

This commit is contained in:
onenewcode 2026-07-16 18:21:01 +08:00 committed by GitHub
parent 68b1777aa4
commit 5bcad3adc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1277 additions and 67 deletions

View File

@ -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<number | null>(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<EditNode[]>([]);
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() {
<span class="flex-1" />
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
<template v-if="isEditing">
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{ t("grid.discard") }}</Button>
<Button size="sm" class="h-6 text-xs" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button>
<div class="flex items-center border rounded-md overflow-hidden mr-1">
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'json' }" :disabled="isSavingDocument" @click="setDocumentEditMode('json')">{{ t("mongo.editModeJson") }}</Button>
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'fields' }" :disabled="isSavingDocument" @click="setDocumentEditMode('fields')">{{ t("mongo.editModeFields") }}</Button>
</div>
<Button v-if="documentEditMode === 'fields'" variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
<Button variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="cancelEdit">{{ t("grid.discard") }}</Button>
<Button size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button>
</template>
</div>
<div v-if="isEditing" class="flex-1 overflow-auto bg-muted/10">
<div class="json-edit min-w-fit p-5" :style="{ ...documentFontStyle, '--mongo-key-width': editKeyWidth }">
<div class="json-edit-brace">{</div>
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
{{ t("mongo.jsonReplaceHint") }}
</div>
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!field.readonlyValue" @remove="requestRemoveField(idx)" />
<div v-if="isEditing" class="flex-1 min-h-0 overflow-hidden bg-muted/10">
<div v-if="documentEditMode === 'json'" class="h-full min-h-0 p-2">
<RedisJsonEditor v-model="editJson" class="h-full rounded border bg-background" :save-disabled="isSavingDocument" :read-only="isSavingDocument" @save="saveDoc" />
</div>
<div v-else class="h-full overflow-auto">
<div class="json-edit min-w-fit p-5" :class="{ 'pointer-events-none opacity-60': isSavingDocument }" :style="{ ...documentFontStyle, '--mongo-key-width': editKeyWidth }" :aria-disabled="isSavingDocument ? 'true' : undefined">
<div class="json-edit-brace">{</div>
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!isSavingDocument && !field.readonlyValue" @remove="requestRemoveField(idx)" />
<div class="json-edit-brace">}</div>
<Button variant="ghost" size="sm" class="json-edit-add" :disabled="isSavingDocument" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
<div class="json-edit-brace">}</div>
</div>
</div>
</div>

View File

@ -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...",

View File

@ -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...",

View File

@ -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...",

View File

@ -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: "フィルター...",

View File

@ -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...",

View File

@ -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: "过滤条件...",

View File

@ -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: "過濾條件……",

View File

@ -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<string, unknown> } | { ok: false; error: Exclude<ParseDocumentStoreJsonDocumentError, "duplicate-key"> } | { 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<string, unknown>) : (parsed as Record<string, unknown>);
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<string, unknown>, options: PrepareDocumentStoreWriteDocumentOptions): Record<string, unknown> {
const next: Record<string, unknown> = { ...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<string, unknown>, 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<string, unknown>).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<string>();
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<string, string> = { '"': '"', "\\": "\\", "/": "/", 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";
}
}

View File

@ -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<string>;
update: (id: string, docJson: string, routing?: string) => Promise<number>;
delete: (id: string, routing?: string) => Promise<number>;
};
/**
* 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<string, unknown>; apis: Pick<DocumentStoreWriteApis, "insert" | "update"> }): Promise<void> {
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<string, unknown>; apis: DocumentStoreWriteApis }): Promise<void> {
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<string, unknown>; explicitId?: string | null; routing?: string; apis: Pick<DocumentStoreWriteApis, "insert" | "update"> }): Promise<void> {
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,
});
}

View File

@ -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<string> {
return documentInsertDocument(connectionId, database, collection, docJson);
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise<string> {
return documentInsertDocument(connectionId, database, collection, docJson, routing);
}
export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
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<string> {
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 }> {

View File

@ -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<string> {
return documentInsertDocument(connectionId, database, collection, docJson);
export async function mongoInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise<string> {
return documentInsertDocument(connectionId, database, collection, docJson, routing);
}
export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string): Promise<string> {
return invoke("document_insert_document", { connectionId, database, collection, docJson });
export async function documentInsertDocument(connectionId: string, database: string, collection: string, docJson: string, routing?: string): Promise<string> {
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 }> {

View File

@ -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<serde_jso
Ok(serde_json::Value::Array(items))
}
pub async fn insert_document(client: &EsClient, index: &str, doc_json: &str) -> Result<String, String> {
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<String, String> {
// 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<serde_json::Value, String> {
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}"#);
}

View File

@ -405,6 +405,7 @@ pub async fn insert_document_core(
database: &str,
collection: &str,
doc_json: &str,
routing: Option<&str>,
) -> Result<String, String> {
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;

View File

@ -323,7 +323,7 @@ pub async fn mongo_insert_document_core(
collection: &str,
doc_json: &str,
) -> Result<String, String> {
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(

View File

@ -76,6 +76,7 @@ pub struct DocumentInsertRequest {
pub database: String,
pub collection: String,
pub doc_json: String,
pub routing: Option<String>,
}
#[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)?;

View File

@ -481,6 +481,7 @@ pub async fn insert_document(
&req.database,
&req.collection,
&req.doc_json,
None,
)
.await
.map_err(AppError)?;

View File

@ -49,12 +49,15 @@ MongoDB 数据库和集合与关系型数据库一同显示在侧边栏中。浏
## JSON 编辑器
对于涉及嵌套字段的复杂编辑,可使用 JSON 编辑器
在文档视图中,可在字段树编辑与整段 JSON 编辑之间切换
- 以 JSON 格式编辑整个文档
- 保存前进行语法验证
- 适用于添加新的嵌套字段或重构文档结构
- **日期保留**:编辑过程中正确保留日期值,维持其原始类型
- 以完整 JSON 对象编辑或新建文档
- 保存前对整段 JSON 做语法与结构校验
- 新增文档时可直接粘贴整段 JSON
- 需要改单个字段时仍可使用字段编辑器
- **更新时整文档替换**JSON 保存会替换文档正文
- **修改标识**:编辑 `_id` 会按新 id 新建文档,并删除旧文档
- **日期与 Extended JSON 保留**:编辑过程中正确保留 `$date`、`$oid`、`$numberLong` 等值
## MongoDB 版本

View File

@ -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

View File

@ -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/);
});

View File

@ -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<string, unknown> = {};
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);
});

View File

@ -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> = {}): 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);
});

View File

@ -85,9 +85,18 @@ pub async fn document_insert_document(
database: String,
collection: String,
doc_json: String,
routing: Option<String>,
) -> Result<String, String> {
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]

View File

@ -271,8 +271,17 @@ pub async fn mongo_insert_document(
database: String,
collection: String,
doc_json: String,
routing: Option<String>,
) -> Result<String, String> {
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]