From 5b500bf1357f92fb0677d62a6c563c5f1c8577a9 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Wed, 10 Jun 2026 16:01:29 +0100 Subject: [PATCH] Preserve jsonb number representation in the JSON editor (#9854) The Query Tool's JSON cell editor pretty-printed jsonb values by parsing and re-stringifying them with json-bignumber. While that preserves big integers, it normalizes decimals through a JS float, so trailing fractional zeros are dropped (10.00 -> 10, 3.140 -> 3.14). Because the reformatted text is what gets written back, opening an unrelated jsonb document and saving it silently rewrote numbers it never edited - which can break applications that rely on the canonical jsonb text. Switch the editor to lossless-json, which preserves the exact numeric representation (big integers and trailing zeros alike), and pass it to the underlying vanilla-jsoneditor as its parser so the in-editor format action and tree/table modes are lossless too. The lossless helpers are centralized in a small json_utils module with unit tests. Closes #9854 --- docs/en_US/release_notes_9_16.rst | 1 + web/package.json | 1 + .../static/js/components/JsonEditor.jsx | 7 ++- .../components/QueryToolDataGrid/Editors.jsx | 13 +++-- .../QueryToolDataGrid/json_utils.js | 33 ++++++++++++ .../javascript/sqleditor/json_utils.spec.js | 53 +++++++++++++++++++ web/yarn.lock | 8 +++ 7 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/json_utils.js create mode 100644 web/regression/javascript/sqleditor/json_utils.spec.js diff --git a/docs/en_US/release_notes_9_16.rst b/docs/en_US/release_notes_9_16.rst index 4f0740cdff0..4ca219d70ac 100644 --- a/docs/en_US/release_notes_9_16.rst +++ b/docs/en_US/release_notes_9_16.rst @@ -43,6 +43,7 @@ Bug fixes | `Issue #9595 `_ - Fix missing ALTER ... SET DEFAULT statements for inherited columns in the generated table SQL/EDIT script. | `Issue #9677 `_ - Fix the Unlogged table toggle in table properties not generating any ALTER TABLE ... SET LOGGED/UNLOGGED statement. | `Issue #9828 `_ - Fix tool calls failing against OpenAI-compatible providers that emit empty/null name, arguments, or id fields in streaming continuation deltas. + | `Issue #9854 `_ - Fix the JSON editor stripping trailing fractional zeros (e.g. 10.00) and rewriting large integers in jsonb values, which corrupted unmodified numbers when saving. | `Issue #9875 `_ - Fixed an issue where EXPLAIN and EXPLAIN ANALYZE failed to execute when blank lines separated clauses in the SQL query. | `Issue #9810 `_ - Use the ServerManager's passfile for the credential gate in connect() so the check matches the passfile actually used for the connection, and warn on conflicting passfile/passexec settings. | `Issue #9892 `_ - Fix blank difference counts on the top-level group rows in Schema Diff. diff --git a/web/package.json b/web/package.json index e4006ab1fbb..1d89f144dab 100644 --- a/web/package.json +++ b/web/package.json @@ -117,6 +117,7 @@ "json-bignumber": "^1.1.1", "leaflet": "^1.9.4", "lodash": "4.*", + "lossless-json": "^4.3.0", "marked": "^18.0.2", "moment": "^2.30.1", "moment-timezone": "^0.6.2", diff --git a/web/pgadmin/static/js/components/JsonEditor.jsx b/web/pgadmin/static/js/components/JsonEditor.jsx index 00b51e34ef0..a77658cd79c 100644 --- a/web/pgadmin/static/js/components/JsonEditor.jsx +++ b/web/pgadmin/static/js/components/JsonEditor.jsx @@ -30,7 +30,12 @@ export default function JsonEditor({setJsonEditorSize, value, options, className ...defaultOptions, ...options, onChange: (updatedContent) => { - options.onChange(currentMode.current == 'text' ? updatedContent.text : JSON.stringify(updatedContent.json)); + // Serialize non-text modes (tree/table) with the configured parser + // so a lossless parser preserves the original number representation + // (big integers, trailing fractional zeros) instead of being + // rewritten by the native JSON serializer. + const serializer = options.parser ?? JSON; + options.onChange(currentMode.current == 'text' ? updatedContent.text : serializer.stringify(updatedContent.json)); options.onValidationError(editor.current.validate()); }, onChangeMode: (mode) => { diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx index a7fbb3f9ef4..50194e28fe9 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/Editors.jsx @@ -13,7 +13,7 @@ import { DefaultButton, PrimaryButton } from '../../../../../../static/js/compon import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import CloseIcon from '@mui/icons-material/Close'; import gettext from 'sources/gettext'; -import JSONBigNumber from 'json-bignumber'; +import { losslessJSONParser, prettifyJSONString, prettifyJSONValue } from './json_utils'; import JsonEditor from '../../../../../../static/js/components/JsonEditor'; import PropTypes from 'prop-types'; import { RowInfoContext } from '.'; @@ -394,13 +394,17 @@ export function JsonTextEditor({row, column, onRowChange, onClose}) { const value = React.useMemo(()=>{ let newVal = row[column.key] ?? null; - /* If jsonb or array */ + /* If jsonb or array. Pretty-print with a lossless JSON parser so that the + * original numeric representation (e.g. big integers and trailing + * fractional zeros like 10.00) is preserved. A plain JSON.parse/stringify + * round-trip would silently rewrite such numbers and corrupt unmodified + * values on save (#9854). */ if(column.column_type_internal === 'jsonb' && !Array.isArray(newVal) && newVal != null) { - newVal = JSONBigNumber.stringify(JSONBigNumber.parse(newVal), null, 2); + newVal = prettifyJSONString(newVal); } else if (Array.isArray(newVal)) { let temp = newVal.map((ele)=>{ if (typeof ele === 'object') { - return JSONBigNumber.stringify(ele, null, 2); + return prettifyJSONValue(ele); } return ele; }); @@ -456,6 +460,7 @@ export function JsonTextEditor({row, column, onRowChange, onClose}) { setJsonEditorSize={setJsonEditorSize} value={localVal??''} options={{ + parser: losslessJSONParser, onChange: onChange, onValidationError: (errors)=>{setHasError(Boolean(errors));} }} diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/json_utils.js b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/json_utils.js new file mode 100644 index 00000000000..c8db747bde9 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/json_utils.js @@ -0,0 +1,33 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import * as LosslessJSON from 'lossless-json'; + +// A lossless JSON parser, with the same { parse, stringify } interface as the +// native JSON object. It preserves the original numeric representation of a +// value - including big integers and trailing fractional zeros such as 10.00 - +// instead of normalizing it the way JSON.parse/JSON.stringify does. +// +// This matters for the JSON cell editor: jsonb values arrive from the server +// as their canonical text, and a normalizing round-trip would silently rewrite +// numbers (e.g. 10.00 -> 10), corrupting unmodified values when the document is +// saved back. See issue #9854. +export const losslessJSONParser = LosslessJSON; + +// Pretty-print a JSON string for the editor, preserving the exact numeric +// representation of every value. +export function prettifyJSONString(value) { + return LosslessJSON.stringify(LosslessJSON.parse(value), null, 2); +} + +// Pretty-print an already-parsed JSON value (e.g. an element of an array +// column) for the editor. +export function prettifyJSONValue(value) { + return LosslessJSON.stringify(value, null, 2); +} diff --git a/web/regression/javascript/sqleditor/json_utils.spec.js b/web/regression/javascript/sqleditor/json_utils.spec.js new file mode 100644 index 00000000000..29303c656fb --- /dev/null +++ b/web/regression/javascript/sqleditor/json_utils.spec.js @@ -0,0 +1,53 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { + losslessJSONParser, + prettifyJSONString, + prettifyJSONValue, +} from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/json_utils'; + +describe('QueryToolDataGrid json_utils', () => { + describe('prettifyJSONString', () => { + it('preserves trailing fractional zeros (#9854)', () => { + // A native JSON.parse/stringify round-trip would turn 10.00 into 10. + expect(prettifyJSONString('10.00')).toBe('10.00'); + expect(prettifyJSONString('{"a":10.00,"b":3.140}')).toBe( + '{\n "a": 10.00,\n "b": 3.140\n}' + ); + }); + + it('preserves big integers beyond Number precision', () => { + expect(prettifyJSONString('{"id":1234567890123456789}')).toBe( + '{\n "id": 1234567890123456789\n}' + ); + }); + + it('pretty-prints with two-space indentation', () => { + expect(prettifyJSONString('{"a":[1,2]}')).toBe( + '{\n "a": [\n 1,\n 2\n ]\n}' + ); + }); + }); + + describe('prettifyJSONValue', () => { + it('pretty-prints an already-parsed value', () => { + expect(prettifyJSONValue({ a: 1 })).toBe('{\n "a": 1\n}'); + }); + }); + + describe('losslessJSONParser', () => { + it('exposes a JSON-compatible parse/stringify interface', () => { + expect(typeof losslessJSONParser.parse).toBe('function'); + expect(typeof losslessJSONParser.stringify).toBe('function'); + // Round-trips a trailing-zero number without normalizing it. + expect(losslessJSONParser.stringify(losslessJSONParser.parse('10.50'))).toBe('10.50'); + }); + }); +}); diff --git a/web/yarn.lock b/web/yarn.lock index 15ab0c94bbe..75f555e325a 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -10286,6 +10286,13 @@ __metadata: languageName: node linkType: hard +"lossless-json@npm:^4.3.0": + version: 4.3.0 + resolution: "lossless-json@npm:4.3.0" + checksum: 10c0/b561eb7f77ef99dbaa0df32fa2407ddcc2c61a0d53297240f0fdd6c2e5f2e0f7d224fb055f84e76467f5ec65547540662c265fcf7a1705497a2d44e323c517df + languageName: node + linkType: hard + "lower-case@npm:^2.0.2": version: 2.0.2 resolution: "lower-case@npm:2.0.2" @@ -13052,6 +13059,7 @@ __metadata: leaflet: "npm:^1.9.4" loader-utils: "npm:^3.3.1" lodash: "npm:4.*" + lossless-json: "npm:^4.3.0" marked: "npm:^18.0.2" mini-css-extract-plugin: "npm:^2.10.2" moment: "npm:^2.30.1"