From 4a675916418a8460b10d43040552cd026d9598fc Mon Sep 17 00:00:00 2001 From: Thanadon Kongkanun <71448810+thanadon-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:31:44 +0700 Subject: [PATCH] fix(grid): keep large JSON numbers exact in copied SQL --- .../__tests__/useDataGridExport.spec.ts | 21 +++++++++++++++++++ .../src/composables/useDataGridExtractor.ts | 19 +++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index a4590c1a7c..8b76d0f5d5 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -1280,6 +1280,27 @@ describe("useDataGridExport prepared row statements", () => { expect(extractDataGridSelection).toHaveBeenNthCalledWith(2, expect.objectContaining({ rows: [[7, { name: "Ada", tags: ["admin"] }]] })); }); + it("keeps JSON cell text in SQL requests when parsing would round large numbers", async () => { + const tableMeta: DataGridTableMeta = { + tableName: "events", + primaryKeys: ["id"], + columns: [ + { name: "id", data_type: "int", is_nullable: false, is_primary_key: true }, + { name: "payload", data_type: "json", is_nullable: true }, + ], + }; + const payload = '{"gameCategoryId":1968549762545291267,"name":"Ada"}'; + const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0, 1], columns: ["id", "payload"], rows: [[7, payload]] }; + vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "copied", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 2 }); + const state = createExportState(tableMeta, ["id", "payload"], matrix, [7, payload]); + + await expect(state.copyWithExtractor("sql-inserts")).resolves.toBe(true); + await expect(state.copyWithExtractor("sql-updates")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenNthCalledWith(1, expect.objectContaining({ rows: [[7, payload]] })); + expect(extractDataGridSelection).toHaveBeenNthCalledWith(2, expect.objectContaining({ rows: [[7, payload]] })); + }); + it("resolves large-value previews before building an extractor request", async () => { const matrix: CellSelectionMatrix = { rowIndexes: [0], diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 9a6a4bfb20..8406c54873 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -21,6 +21,7 @@ import { import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload"; import { formatError } from "@/lib/backend/errorUtils"; +import { parseJsonPreservingLargeNumbers, stringifyJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; import { tableMetaWithoutOptionalDatabaseQualifier } from "@/lib/table/tableSelectSql"; import type { DatabaseType } from "@/types/database"; @@ -35,6 +36,7 @@ interface ExtractorRequestSource { sourceColumnIndexes: number[]; columnTypes: Array; normalizeValues: boolean; + keepUnsafeJsonText: boolean; presentBinaryText: boolean; rawRows: unknown[][]; } @@ -76,10 +78,13 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const hasUnsupportedDiscreteSelection = computed(() => options.hasCellSelection.value && options.selectedCellMatrix.value === null); const requestSources = new WeakMap(); - function normalizeCellValue(value: unknown, columnType: string | undefined): unknown { + function normalizeCellValue(value: unknown, columnType: string | undefined, keepUnsafeJsonText: boolean): unknown { if (typeof value !== "string" || columnType?.trim().toLowerCase() !== "json") return value; try { - return JSON.parse(value); + const parsed = JSON.parse(value); + // SQL literals must keep numbers JavaScript would round (e.g. 64-bit ids above 2^53), so send the original text. + if (keepUnsafeJsonText && stringifyJsonPreservingLargeNumbers(parseJsonPreservingLargeNumbers(value)) !== JSON.stringify(parsed)) return value; + return parsed; } catch { return value; } @@ -87,12 +92,12 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { // 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x` 还原为原始字符串。 // SQL 必须继续持有 hex 才能保证 round-trip;rawRows 则始终保存原值,供 DBX 内部网格回粘使用。 - function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, presentBinaryText: boolean, columnIndex: number): unknown { + function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, keepUnsafeJsonText: boolean, presentBinaryText: boolean, columnIndex: number): unknown { if (presentBinaryText) { const text = binaryCellClipboardText(value, columnType, options.databaseType.value); if (text !== null) return text; } - const normalized = normalizeValues ? normalizeCellValue(value, columnType) : value; + const normalized = normalizeValues ? normalizeCellValue(value, columnType, keepUnsafeJsonText) : value; const externalValue = options.externalCellValue?.(normalized, columnIndex); return externalValue === undefined ? normalized : externalValue; } @@ -214,9 +219,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined); const columnTypes = requiredSourceIndexes.map((sourceIndex) => columnTypesBySource.get(sourceIndex)); const normalizeValues = descriptor.category === "json" || descriptor.category === "sql"; + const keepUnsafeJsonText = descriptor.category === "sql"; const presentBinaryText = descriptor.category !== "sql"; const rawRows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => row[sourceIndex])); - const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, presentBinaryText, index))); + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, keepUnsafeJsonText, presentBinaryText, index))); const tableMeta = descriptor.category === "sql" ? tableMetaWithoutOptionalDatabaseQualifier( @@ -245,6 +251,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { sourceColumnIndexes: requiredSourceIndexes, columnTypes, normalizeValues, + keepUnsafeJsonText, presentBinaryText, rawRows, }); @@ -279,7 +286,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { return sourceIndex !== undefined && values.has(sourceIndex) ? values.get(sourceIndex) : value; }); }); - const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.presentBinaryText, index))); + const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.keepUnsafeJsonText, limitedSource.presentBinaryText, index))); const resolvedRequest = { ...request, rows }; requestSources.set(resolvedRequest, { ...limitedSource, rawRows }); return resolvedRequest;