fix(mongodb): expand nested JSON strings when copying rows as JSON

This commit is contained in:
zipg 2026-06-28 18:57:03 +08:00 committed by GitHub
parent 56fb49058f
commit 585a6e2d40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 177 additions and 1 deletions

View File

@ -11,6 +11,7 @@ import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, ty
import { formatSqlInsert } from "@/lib/exportFormats";
import { uuid } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settingsStore";
import { expandNestedJsonStringsForCopy } from "@/lib/jsonCopyValue";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { QueryResultExportRequest } from "@/lib/api";
@ -395,7 +396,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyRowsAsJson(items: RowItem[]) {
if (items.length === 0) return;
const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject);
await copyText(JSON.stringify(value, null, 2));
const copyValue = options.databaseType.value === "mongodb" ? expandNestedJsonStringsForCopy(value) : value;
await copyText(JSON.stringify(copyValue, null, 2));
}
// --- Cell/row copy ---

View File

@ -0,0 +1,30 @@
export function expandNestedJsonStringsForCopy(value: unknown): unknown {
if (typeof value === "string") {
return parseNestedJsonString(value) ?? value;
}
if (Array.isArray(value)) {
return value.map(expandNestedJsonStringsForCopy);
}
if (isPlainObject(value)) {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandNestedJsonStringsForCopy(item)]));
}
return value;
}
function parseNestedJsonString(value: string): unknown | undefined {
const trimmed = value.trim();
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return undefined;
try {
return expandNestedJsonStringsForCopy(JSON.parse(trimmed));
} catch {
return undefined;
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,44 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { expandNestedJsonStringsForCopy } from "../../apps/desktop/src/lib/jsonCopyValue.ts";
test("expands nested JSON strings for copied rows", () => {
const value = {
_id: "67218700e884ae1f527640b6",
accountId: 581,
data: '{"endingBalance":{"beginningBalance":"0","endingBalance":"20000","endingDate":"2024-10-30"},"financeChargeInfo":null,"interestChargeInfo":null,"Line":[]}',
status: "draft",
};
assert.deepEqual(expandNestedJsonStringsForCopy(value), {
_id: "67218700e884ae1f527640b6",
accountId: 581,
data: {
endingBalance: {
beginningBalance: "0",
endingBalance: "20000",
endingDate: "2024-10-30",
},
financeChargeInfo: null,
interestChargeInfo: null,
Line: [],
},
status: "draft",
});
});
test("recursively expands JSON strings in arrays and objects", () => {
const value = {
items: ['{"id":1,"meta":"{\\"ok\\":true}"}', "plain text"],
};
assert.deepEqual(expandNestedJsonStringsForCopy(value), {
items: [{ id: 1, meta: { ok: true } }, "plain text"],
});
});
test("keeps non-object JSON-like cell strings unchanged", () => {
assert.equal(expandNestedJsonStringsForCopy("123"), "123");
assert.equal(expandNestedJsonStringsForCopy('"text"'), '"text"');
assert.equal(expandNestedJsonStringsForCopy("2024-10-30T01:08:14.454Z"), "2024-10-30T01:08:14.454Z");
});

View File

@ -15,8 +15,12 @@ const apiMock = vi.hoisted(() => ({
exportQueryResultMarkdown: vi.fn(),
exportQueryResultsXlsx: vi.fn(),
}));
const clipboardMock = vi.hoisted(() => ({
copyToClipboard: vi.fn(),
}));
vi.mock("@/lib/api", () => apiMock);
vi.mock("@/lib/clipboard", () => clipboardMock);
vi.mock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
@ -184,6 +188,7 @@ function buildTableDataExportHarness() {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
clipboardMock.copyToClipboard.mockResolvedValue(undefined);
apiMock.startQueryResultExport.mockImplementation(async (_request, onProgress) => {
onProgress({ exportId: _request.exportId, tableName: "", rowsExported: 2, totalRows: 2, status: "Done" });
return { exportId: _request.exportId, tableName: "", rowsExported: 2, totalRows: 2, status: "Done" };
@ -194,6 +199,101 @@ beforeEach(() => {
});
});
test("copy row JSON expands nested JSON strings", async () => {
const contextCell = ref({ rowId: 1, rowIndex: 0, col: 0 });
const jsonString = '{"endingBalance":{"beginningBalance":"0","endingBalance":"20000","endingDate":"2024-10-30"},"financeChargeInfo":null,"interestChargeInfo":null,"Line":[]}';
const row = {
id: 1,
data: ["67218700e884ae1f527640b6", jsonString, "draft"],
isNew: false,
isDeleted: false,
isDirtyCol: [false, false, false],
status: "",
};
const composable = useDataGridExport({
columns: computed(() => ["_id", "data", "status"]),
displayItems: computed(() => [row]),
sql: computed(() => undefined),
tableMeta: computed(() => undefined),
databaseType: computed(() => "mongodb"),
connectionId: computed(() => "conn-1"),
database: computed(() => "db"),
context: computed(() => "results"),
sourceColumns: computed(() => undefined),
columnTypes: computed(() => undefined),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedRange: computed(() => null),
contextCell,
getRowItem: () => row,
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
});
await composable.copyRow();
assert.equal(clipboardMock.copyToClipboard.mock.calls.length, 1);
assert.deepEqual(JSON.parse(clipboardMock.copyToClipboard.mock.calls[0][0]), {
_id: "67218700e884ae1f527640b6",
data: {
endingBalance: {
beginningBalance: "0",
endingBalance: "20000",
endingDate: "2024-10-30",
},
financeChargeInfo: null,
interestChargeInfo: null,
Line: [],
},
status: "draft",
});
});
test("copy row JSON keeps nested JSON strings for non-MongoDB rows", async () => {
const contextCell = ref({ rowId: 1, rowIndex: 0, col: 0 });
const jsonString = '{"enabled":true}';
const row = {
id: 1,
data: [1, jsonString],
isNew: false,
isDeleted: false,
isDirtyCol: [false, false],
status: "",
};
const composable = useDataGridExport({
columns: computed(() => ["id", "payload"]),
displayItems: computed(() => [row]),
sql: computed(() => undefined),
tableMeta: computed(() => undefined),
databaseType: computed(() => "mysql"),
connectionId: computed(() => "conn-1"),
database: computed(() => "db"),
context: computed(() => "table"),
sourceColumns: computed(() => undefined),
columnTypes: computed(() => undefined),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedRange: computed(() => null),
contextCell,
getRowItem: () => row,
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
});
await composable.copyRow();
assert.deepEqual(JSON.parse(clipboardMock.copyToClipboard.mock.calls[0][0]), {
id: 1,
payload: jsonString,
});
});
test("default data grid export file names use sanitized base names and compact local timestamps", () => {
vi.useFakeTimers();
try {