feat(mongo): add whole-document JSON editing

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-16 18:21:01 +08:00
1 parent 68b1777aa4
commit 5bcad3adc6
24 files changed
+1277 -67

No files matched your search

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