feat(redis): support serialized JSON string presentation

This commit is contained in:
lxk955
2026-10-11 08:45:10 +08:00
committed by GitHub
parent b799cf38df
commit 035fecd17b
8 changed files with 151 additions and 12 deletions
@@ -1346,6 +1346,10 @@ function rememberRedisValueCodec(codec: RedisValueCodec) {
function formatJsonText(raw: string, decodeUnicode: boolean): string | null {
try {
const detail = parseRedisJsonDetail(raw);
if (detail) {
return decodeUnicode ? decodeJsonUnicodeEscapes(detail.formattedText) : detail.formattedText;
}
// Keep Redis JSON baselines source-preserving (duplicate keys, number text),
// then apply the display-only unicode decode when the user chose "decoded".
const formatted = formatJsonSource(raw, 2);
@@ -1739,7 +1743,7 @@ async function saveString() {
let value = editValue.value;
// Compact whenever this draft is/was JSON-edited, even if the user switched tabs before Save.
if (stringValueView.value === "json" || stringDraftFormat.value === "json") {
const normalized = normalizeRedisJsonDraft(jsonDraftForSave(stringJsonRawBaseline.value, value));
const normalized = normalizeRedisJsonDraft(jsonDraftForSave(stringJsonRawBaseline.value, value), stringValueDetail.value?.json?.serializedLevel);
if (!normalized.ok) {
toast(t("redis.jsonFormatError"), 3000);
return;
@@ -2188,7 +2192,7 @@ async function saveMemberEdit() {
let writeValue = memberEditValue.value;
// Hash JSON drafts may still be open under UTF-8 after a format switch; keep compact writes.
if (savingHashJson) {
const normalized = normalizeRedisJsonDraft(jsonDraftForSave(memberJsonRawBaseline.value, writeValue));
const normalized = normalizeRedisJsonDraft(jsonDraftForSave(memberJsonRawBaseline.value, writeValue), selectedMemberDetail.value?.json?.serializedLevel);
if (!normalized.ok) {
toast(t("redis.jsonFormatError"), 3000);
return;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { compactJsonText, valueEditorActions } from "@/lib/dataGrid/cellDetailPresentation";
import { compactJsonText, formatJsonText, looksLikeJsonContainerText, valueEditorActions } from "@/lib/dataGrid/cellDetailPresentation";
describe("cellDetailPresentation", () => {
it("offers compact JSON beside format JSON for editable JSON values", () => {
@@ -7,4 +7,13 @@ describe("cellDetailPresentation", () => {
expect(valueEditorActions({ canSetNull: false, canFormatJson: false })).toEqual(["restoreOriginal"]);
expect(compactJsonText('{\n "name": "DBX",\n "enabled": true\n}')).toBe('{"name":"DBX","enabled":true}');
});
it("formats serialized JSON strings into pretty-printed JSON (issue #11636)", () => {
const serialized = '"{\\"abc\\":\\"\\"}"';
expect(looksLikeJsonContainerText(serialized)).toBe(true);
expect(formatJsonText(serialized)).toBe('{\n "abc": ""\n}');
// Scalar string in quotes is not treated as a container
expect(looksLikeJsonContainerText('"hello"')).toBe(false);
});
});
@@ -29,4 +29,14 @@ describe("native RedisJSON editor", () => {
const saved = normalizeRedisJsonDraft(opened.json!.formattedText);
expect(saved).toEqual({ ok: true, compactText: DUPLICATE_MEMBER_COMPACT });
});
it("string and hash serialized JSON editor open+save keeps serialized quotes intact (issue #11636)", () => {
const SERIALIZED_COMPACT = '"{\\"role\\":\\"reader\\",\\"role\\":\\"writer\\"}"';
const opened = formatRedisMemberDetail(SERIALIZED_COMPACT, { allowJsonText: true });
expect(opened.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY);
expect(opened.json?.serializedLevel).toBe(1);
const saved = normalizeRedisJsonDraft(opened.json!.formattedText, opened.json!.serializedLevel);
expect(saved).toEqual({ ok: true, compactText: SERIALIZED_COMPACT });
});
});
@@ -11,6 +11,7 @@ import {
jsonToXmlText,
jsonToYamlText,
normalizeRedisJsonDraft,
parseRedisJsonDetail,
preferredRedisValueFormat,
redisClipboardSafeText,
redisJsonValueText,
@@ -411,4 +412,57 @@ describe("redisValuePresentation", () => {
it("uses a valid fallback tag for empty JSON keys", () => {
expect(jsonToXmlText({ "": 1 })).toBe('<?xml version="1.0" encoding="UTF-8"?>\n<root>\n <item>1</item>\n</root>');
});
it("unwraps serialized JSON strings into structured JSON containers (issue #11636)", () => {
const serializedObject = '"{\\"abc\\":\\"\\"}"';
const detail = parseRedisJsonDetail(serializedObject);
expect(detail).not.toBeNull();
expect(detail?.formattedText).toBe('{\n "abc": ""\n}');
expect(detail?.value).toEqual({ abc: "" });
expect(detail?.serializedLevel).toBe(1);
const serializedArray = '"[{\\"id\\":1},{\\"id\\":2}]"';
const arrayDetail = parseRedisJsonDetail(serializedArray);
expect(arrayDetail).not.toBeNull();
expect(arrayDetail?.value).toEqual([{ id: 1 }, { id: 2 }]);
expect(arrayDetail?.serializedLevel).toBe(1);
// Double-serialized JSON string unwraps through multiple levels
const doubleSerialized = JSON.stringify(JSON.stringify(JSON.stringify({ nested: true })));
const doubleDetail = parseRedisJsonDetail(doubleSerialized);
expect(doubleDetail).not.toBeNull();
expect(doubleDetail?.value).toEqual({ nested: true });
expect(doubleDetail?.serializedLevel).toBe(2);
});
it("auto-detects serialized JSON containers and sets format to json (issue #11636)", () => {
const memberDetail = formatRedisMemberDetail('"{\\"abc\\":\\"\\"}"', { allowJsonText: true });
expect(memberDetail.json).toBeDefined();
expect(isRedisJsonContainerValue(memberDetail.json?.value)).toBe(true);
expect(autoRedisValueFormat(memberDetail, null)).toBe("json");
});
it("normalizes serialized JSON drafts with serializedLevel (issue #11636)", () => {
const draft = '{\n "abc": "xyz"\n}';
expect(normalizeRedisJsonDraft(draft, 1)).toEqual({
ok: true,
compactText: '"{\\"abc\\":\\"xyz\\"}"',
});
expect(normalizeRedisJsonDraft(draft, 2)).toEqual({
ok: true,
compactText: '"\\"{\\\\\\"abc\\\\\\":\\\\\\"xyz\\\\\\"}\\""',
});
});
it("keeps serialized scalar strings as plain text without unwrapping", () => {
const scalarString = '"hello world"';
const detail = parseRedisJsonDetail(scalarString);
expect(detail?.formattedText).toBe('"hello world"');
expect(detail?.value).toBe("hello world");
expect(detail?.serializedLevel).toBeUndefined();
expect(isRedisJsonContainerValue(detail?.value)).toBe(false);
const memberDetail = formatRedisMemberDetail(scalarString, { allowJsonText: true });
expect(autoRedisValueFormat(memberDetail, null)).toBe("utf8");
});
});
@@ -171,6 +171,10 @@ describe("Cassandra collection cell detail presentation", () => {
expect(detailFor("{'color': 'blue'}")!.formattedJson).toBe("");
expect(detailFor("not a literal", "cassandra")!.formattedJson).toBe("");
});
it("formats serialized JSON strings into formattedJson (issue #11636)", () => {
expect(detailFor('"{\\"abc\\":\\"\\"}"')!.formattedJson).toBe('{\n "abc": ""\n}');
});
});
describe("Mongo collection cell detail presentation", () => {
@@ -93,6 +93,19 @@ export function formatJsonText(text: string): string | undefined {
if (!trimmed) return undefined;
if (trimmed.length > CELL_DETAIL_JSON_FORMAT_MAX_LENGTH) return undefined;
try {
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === "string") {
const inner = parsed.trim();
if (inner.startsWith("{") || inner.startsWith("[")) {
return safeJsonFormat(inner, 2);
}
}
} catch {
// fallback to normal format
}
}
return safeJsonFormat(trimmed, 2);
} catch {
return undefined;
@@ -112,5 +125,17 @@ export function compactJsonText(text: string): string | undefined {
export function looksLikeJsonContainerText(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return true;
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === "string") {
const inner = parsed.trim();
return inner.startsWith("{") || inner.startsWith("[");
}
} catch {
// not valid JSON string
}
}
return false;
}
@@ -1,7 +1,7 @@
import { cellImagePreviewUrl } from "@/lib/dataGrid/cellImageUrl";
import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload";
import { clipboardCellValue, displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { formatJsonText } from "@/lib/dataGrid/cellDetailPresentation";
import { formatJsonText, looksLikeJsonContainerText } from "@/lib/dataGrid/cellDetailPresentation";
import { cqlLiteralToJsonText } from "@/lib/dataGrid/cqlLiteralJson";
import type { DatabaseType } from "@/types/database";
@@ -307,8 +307,7 @@ function detailFormattedJson(value: string, databaseType: DatabaseType | undefin
}
function looksLikeJsonContainer(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");
return looksLikeJsonContainerText(text);
}
function previewText(text: string): string {
@@ -62,6 +62,7 @@ export interface RedisJsonDetail {
rawText: string;
formattedText: string;
value: unknown;
serializedLevel?: number;
}
export type RedisJsonDraftNormalizationResult =
@@ -351,12 +352,40 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
try {
// Pretty-print from source tokens so duplicate members and number spellings
// stay intact when Redis string/hash values open in the JSON editor.
const formattedText = formatJsonSource(trimmed, 2);
const parsed = parseJsonPreservingLargeNumbers(trimmed);
let formattedText = formatJsonSource(trimmed, 2);
let parsed = parseJsonPreservingLargeNumbers(trimmed);
let serializedLevel = 0;
// Issue #11636: Support serialized JSON strings (e.g. "\"{\\\"abc\\\":\\\"\\\"}\"")
// produced by object mappers / serializers. Unwrap into a structured JSON container
// so it formats as an expandable object/array tree rather than an escaped scalar.
while (typeof parsed === "string") {
const innerTrimmed = parsed.trim();
if (!innerTrimmed.startsWith("{") && !innerTrimmed.startsWith("[") && !innerTrimmed.startsWith('"')) {
break;
}
try {
const nextFormatted = formatJsonSource(innerTrimmed, 2);
const nextParsed = parseJsonPreservingLargeNumbers(innerTrimmed);
if (typeof nextParsed === "string") {
const nextTrimmed = nextParsed.trim();
if (!nextTrimmed.startsWith("{") && !nextTrimmed.startsWith("[")) {
break;
}
}
formattedText = nextFormatted;
parsed = nextParsed;
serializedLevel += 1;
} catch {
break;
}
}
return {
rawText: value,
formattedText,
value: parsed,
...(serializedLevel > 0 ? { serializedLevel } : {}),
};
} catch {
return null;
@@ -366,11 +395,16 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
/**
* Validates a JSON editor draft and produces the compact text Redis should
* store. Source-preserving minification keeps high-precision numbers and
* duplicate object members intact.
* duplicate object members intact. When serializedLevel > 0 (issue #11636),
* wraps the compact text back into the expected serialized string representation.
*/
export function normalizeRedisJsonDraft(text: string): RedisJsonDraftNormalizationResult {
export function normalizeRedisJsonDraft(text: string, serializedLevel = 0): RedisJsonDraftNormalizationResult {
try {
return { ok: true, compactText: formatJsonSource(text) };
let compactText = formatJsonSource(text);
for (let i = 0; i < serializedLevel; i += 1) {
compactText = JSON.stringify(compactText);
}
return { ok: true, compactText };
} catch {
return { ok: false, error: "invalid_json" };
}