-
Notifications
You must be signed in to change notification settings - Fork 0
Json numeric type design #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 5 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7186065
Issue #119 Document JsonNumber numeric conversion design choices
cursoragent 2e222bd
Issue #119 Add runnable numeric design choice examples
cursoragent 1196242
Issue #119 Clarify BigDecimal text-policy examples
cursoragent 3f62ed2
Issue #119 Add counter examples and native mapping guidance
cursoragent 0903da3
Issue #119 Add counterexamples and native mapping demo
cursoragent 07c0545
Issue #119 Fold numeric design notes into README
cursoragent 46da612
Issue #119 Update CI expected test total
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| # Design choices: numeric handling (`JsonNumber`, BigDecimal/BigInteger) | ||
|
|
||
| This repository is a **backport** of the upstream OpenJDK sandbox `java.util.json` work (mirrored here as `jdk.sandbox.java.util.json`). That matters for “why did X disappear?” questions: | ||
|
|
||
| - This repo intentionally avoids *inventing* new public API shapes that diverge from upstream, because doing so makes syncing harder and breaks the point of the backport. | ||
| - When upstream removes or reshapes API, this repo follows. | ||
|
|
||
| ## What changed (the “story”) | ||
|
|
||
| Older revisions of this backport carried some convenience entry points that accepted `java.math.BigDecimal` and `java.math.BigInteger` when building JSON numbers. | ||
|
|
||
| During the last upstream sync, those entry points were removed. **That is consistent with the upstream design direction**: keep `JsonNumber`’s public surface area small and make **lossless numeric interoperability** flow through the **lexical JSON number text** (`JsonNumber.toString()`), not through a growing matrix of overloads. | ||
|
|
||
| Put differently: the design is “JSON numbers are text first”, not “JSON numbers are a Java numeric tower”. | ||
|
|
||
| ## `JsonNumber` is not a primitive (and that’s the point) | ||
|
|
||
| The core abstraction here is `JsonValue`, a **sealed interface** with one subtype per JSON kind: | ||
|
|
||
| - `JsonString` | ||
| - `JsonNumber` | ||
| - `JsonObject` | ||
| - `JsonArray` | ||
| - `JsonBoolean` | ||
| - `JsonNull` | ||
|
|
||
| So `JsonNumber` is not intended to *replace* Java numeric primitives; it’s the JSON-layer representation of “a number token in a JSON document”. | ||
|
|
||
| The deliberate split is: | ||
|
|
||
| - **JSON layer**: preserve what was written (especially for numbers), keep round-tripping sane, avoid choosing a single “native numeric type” too early. | ||
| - **Application layer**: *you* decide what “native” means (long? double? BigDecimal? BigInteger? domain-specific types?), and you do that conversion explicitly. | ||
|
|
||
| ## Why upstream prefers `String` (and why BigDecimal constructors are a footgun) | ||
|
|
||
| ### 1) JSON numbers are arbitrary precision *text* | ||
|
|
||
| RFC 8259 defines the *syntax* of a JSON number; it does **not** define a fixed precision or a single canonical format. The API aligns with that by treating the number as a string that: | ||
|
|
||
| - can be preserved exactly when parsed from a document | ||
| - can be created from a string when you need exact control | ||
|
|
||
| ### 2) `BigDecimal`/`BigInteger` introduce formatting policy into the API | ||
|
|
||
| If `JsonNumber` has `of(BigDecimal)` / `of(BigInteger)`: | ||
|
|
||
| - which textual form should be used (`toString()` vs `toPlainString()`)? | ||
| - should `-0` be preserved, normalized, or rejected? | ||
| - should `1e2` round-trip as `1e2` or normalize to `100`? | ||
|
|
||
| Any choice becomes a **semantic commitment**: it changes `toString()`, equality and hash behavior, and round-trip characteristics. | ||
|
|
||
| Upstream avoids baking those policy decisions into the core JSON API by: | ||
|
|
||
| - providing `JsonNumber.of(String)` as the “I know what text I want” factory | ||
| - documenting that you can always interoperate with arbitrary precision Java numerics by converting *from* `toString()` | ||
|
|
||
| This intent is explicitly documented in `JsonNumber`’s own `@apiNote`. | ||
|
|
||
| ### 3) Minimal factories avoid overload explosion | ||
|
|
||
| JSON object/array construction in this API already leans toward: | ||
|
|
||
| - immutable values | ||
| - static factories (`...of(...)`) | ||
| - pattern matching / sealed types when consuming values | ||
|
|
||
| That style is a natural fit for “a few sharp entry points” rather than the legacy OO pattern of ever-expanding constructor overloads for every “convenient” numeric type. | ||
|
|
||
| ## Recommended recipes (lossless + explicit) | ||
|
|
||
| ### Parse → BigDecimal (lossless) | ||
|
|
||
| ```java | ||
| var n = (JsonNumber) Json.parse("3.141592653589793238462643383279"); | ||
| var bd = new BigDecimal(n.toString()); // exact | ||
| ``` | ||
|
|
||
| ### Counter-example: converting to `double` can lose information | ||
|
|
||
| ```java | ||
| var n = (JsonNumber) Json.parse("3.141592653589793238462643383279"); | ||
| double d = n.toDouble(); // finite, but lossy | ||
| // BigDecimal.valueOf(d) is NOT equal to the original high-precision value | ||
| ``` | ||
|
|
||
| ### Counter-example: converting to `long` can throw (even for numbers) | ||
|
|
||
| ```java | ||
| var n = (JsonNumber) Json.parse("5.5"); | ||
| n.toLong(); // throws JsonAssertionException (not an integral value) | ||
| ``` | ||
|
|
||
| ### Counter-example: converting to `double` can throw (range overflow) | ||
|
|
||
| ```java | ||
| var n = (JsonNumber) Json.parse("1e309"); | ||
| n.toDouble(); // throws JsonAssertionException (outside finite double range) | ||
| ``` | ||
|
|
||
| ### Parse → BigInteger (lossless, when integral) | ||
|
|
||
| ```java | ||
| var n = (JsonNumber) Json.parse("1.23e2"); | ||
| var bi = new BigDecimal(n.toString()).toBigIntegerExact(); // 123 | ||
| ``` | ||
|
|
||
| ### BigDecimal → JsonNumber (pick your textual policy) | ||
|
|
||
| If you want to preserve the *mathematical* value without scientific notation: | ||
|
|
||
| ```java | ||
| var bd = new BigDecimal("1000"); | ||
| var n = JsonNumber.of(bd.toPlainString()); // "1000" | ||
| ``` | ||
|
|
||
| If you’re fine with scientific notation when `BigDecimal` chooses it: | ||
|
|
||
| ```java | ||
| var bd = new BigDecimal("1E+3"); | ||
| var n = JsonNumber.of(bd.toString()); // "1E+3" (still valid JSON number text) | ||
| ``` | ||
|
|
||
| ### JSON lexical preservation is not numeric normalization | ||
|
|
||
| Two JSON numbers can represent the same numeric value but still be different JSON texts: | ||
|
|
||
| ```java | ||
| var a = (JsonNumber) Json.parse("1e2"); | ||
| var b = (JsonNumber) Json.parse("100"); | ||
| assert !a.toString().equals(b.toString()); // lexical difference preserved | ||
| ``` | ||
|
|
||
| If your application needs *numeric* equality or canonicalization, perform it explicitly with `BigDecimal` (or your own policy), rather than relying on the JSON value object to do it implicitly. | ||
|
|
||
| ## Ergonomics: mapping `JsonValue` to native Java types (pattern matching) | ||
|
|
||
| If you want the “old style” `Map` / `List` / primitives view, you can build it explicitly using a `switch` over the sealed `JsonValue` hierarchy. | ||
|
|
||
| One pragmatic policy for numbers is: | ||
|
|
||
| - try `toLong()` first (exact integer in range) | ||
| - otherwise fall back to `BigDecimal` from `toString()` (lossless) | ||
|
|
||
| ```java | ||
| static Object toNative(JsonValue v) { | ||
| return switch (v) { | ||
| case JsonNull ignored -> null; | ||
| case JsonBoolean b -> b.bool(); | ||
| case JsonString s -> s.string(); | ||
| case JsonNumber n -> { | ||
| try { | ||
| yield n.toLong(); | ||
| } catch (JsonAssertionException ignored) { | ||
| yield new BigDecimal(n.toString()); | ||
| } | ||
| } | ||
| case JsonArray a -> a.elements().stream().map(Design::toNative).toList(); | ||
| case JsonObject o -> o.members().entrySet().stream() | ||
| .collect(Collectors.toMap(Map.Entry::getKey, e -> toNative(e.getValue()))); | ||
| }; | ||
| } | ||
| ``` | ||
|
|
||
| This gives you native ergonomics **without** forcing the core JSON API to guess which numeric type you wanted. | ||
|
|
||
| ## Runnable examples | ||
|
|
||
| This document’s examples are mirrored in code: | ||
|
|
||
| - `json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java` | ||
| - `json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java` | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
121 changes: 121 additions & 0 deletions
121
json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package jdk.sandbox.java.util.json; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.math.BigInteger; | ||
| import java.util.Map; | ||
| import java.util.logging.Logger; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
|
||
| public class DesignChoicesNumberExamplesTest { | ||
| private static final Logger LOGGER = Logger.getLogger(DesignChoicesNumberExamplesTest.class.getName()); | ||
|
|
||
| @Test | ||
| void parseHighPrecisionNumberIsLosslessViaToString() { | ||
| LOGGER.info("Executing parseHighPrecisionNumberIsLosslessViaToString"); | ||
|
|
||
| var text = "3.141592653589793238462643383279"; | ||
| var n = (JsonNumber) Json.parse(text); | ||
|
|
||
| assertThat(n.toString()).isEqualTo(text); | ||
| assertThat(new BigDecimal(n.toString())).isEqualByComparingTo(new BigDecimal(text)); | ||
| } | ||
|
|
||
| @Test | ||
| void convertingToDoubleIsPotentiallyLossy() { | ||
| LOGGER.info("Executing convertingToDoubleIsPotentiallyLossy"); | ||
|
|
||
| var text = "3.141592653589793238462643383279"; | ||
| var n = (JsonNumber) Json.parse(text); | ||
|
|
||
| var lossless = new BigDecimal(n.toString()); | ||
| var lossy = new BigDecimal(Double.toString(n.toDouble())); | ||
|
|
||
| assertThat(lossy).isNotEqualByComparingTo(lossless); | ||
| } | ||
|
|
||
| @Test | ||
| void convertingNonIntegralNumberToLongThrows() { | ||
| LOGGER.info("Executing convertingNonIntegralNumberToLongThrows"); | ||
|
|
||
| var n = (JsonNumber) Json.parse("5.5"); | ||
| assertThatThrownBy(n::toLong) | ||
| .isInstanceOf(JsonAssertionException.class); | ||
| } | ||
|
|
||
| @Test | ||
| void convertingOutOfRangeNumberToDoubleThrows() { | ||
| LOGGER.info("Executing convertingOutOfRangeNumberToDoubleThrows"); | ||
|
|
||
| var n = (JsonNumber) Json.parse("1e309"); | ||
| assertThatThrownBy(n::toDouble) | ||
| .isInstanceOf(JsonAssertionException.class); | ||
| } | ||
|
|
||
| @Test | ||
| void parseExponentFormToBigIntegerExactWorks() { | ||
| LOGGER.info("Executing parseExponentFormToBigIntegerExactWorks"); | ||
|
|
||
| var n = (JsonNumber) Json.parse("1.23e2"); | ||
| BigInteger bi = new BigDecimal(n.toString()).toBigIntegerExact(); | ||
|
|
||
| assertThat(bi).isEqualTo(BigInteger.valueOf(123)); | ||
| } | ||
|
|
||
| @Test | ||
| void bigDecimalToJsonNumberRequiresChoosingATextPolicy() { | ||
| LOGGER.info("Executing bigDecimalToJsonNumberRequiresChoosingATextPolicy"); | ||
|
|
||
| // Using toPlainString() for a plain number representation | ||
| var bdPlain = new BigDecimal("1000"); | ||
|
|
||
| var plain = JsonNumber.of(bdPlain.toPlainString()); | ||
| assertThat(plain.toString()).isEqualTo("1000"); | ||
|
|
||
| // Using toString(), which may produce scientific notation | ||
| var bdScientific = new BigDecimal("1E+3"); | ||
| var scientific = JsonNumber.of(bdScientific.toString()); | ||
| assertThat(scientific.toString()).isEqualTo("1E+3"); | ||
| } | ||
|
|
||
| @Test | ||
| void lexicalPreservationDiffersFromNumericNormalization() { | ||
| LOGGER.info("Executing lexicalPreservationDiffersFromNumericNormalization"); | ||
|
|
||
| var a = (JsonNumber) Json.parse("1e2"); | ||
| var b = (JsonNumber) Json.parse("100"); | ||
|
|
||
| // lexical forms differ | ||
| assertThat(a.toString()).isEqualTo("1e2"); | ||
| assertThat(b.toString()).isEqualTo("100"); | ||
|
|
||
| // but numeric values compare equal when canonicalized explicitly | ||
| assertThat(new BigDecimal(a.toString())).isEqualByComparingTo(new BigDecimal(b.toString())); | ||
| } | ||
|
|
||
| @Test | ||
| void mappingToNativeTypesUsesPatternMatchingAndExplicitNumberPolicy() { | ||
| LOGGER.info("Executing mappingToNativeTypesUsesPatternMatchingAndExplicitNumberPolicy"); | ||
|
|
||
| JsonValue json = Json.parse(""" | ||
| { | ||
| "smallInt": 42, | ||
| "decimal": 5.5 | ||
| } | ||
| """); | ||
|
|
||
| Object nativeValue = jdk.sandbox.java.util.json.examples.DesignChoicesExamples.toNative(json); | ||
| assertThat(nativeValue).isInstanceOf(Map.class); | ||
|
|
||
| @SuppressWarnings("unchecked") | ||
| var map = (Map<String, Object>) nativeValue; | ||
|
|
||
| assertThat(map.get("smallInt")).isEqualTo(42L); | ||
| assertThat(map.get("decimal")).isInstanceOf(BigDecimal.class); | ||
| assertThat((BigDecimal) map.get("decimal")).isEqualByComparingTo(new BigDecimal("5.5")); | ||
| } | ||
| } | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic in this test can be made clearer and more consistent. The first part of the test defines a
BigDecimalvariablethousand, but the second part creates aBigDecimalinline. For better readability and to more clearly demonstrate the policies, consider defining separate, descriptively named variables for both cases. This makes the intent of each part of the test more explicit.