mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(redis): support serialized JSON string presentation
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user