From 7186065c062aa530132cefe2f6244f2dc1cc8fc7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 15:30:30 +0000 Subject: [PATCH 1/7] Issue #119 Document JsonNumber numeric conversion design choices Co-authored-by: simbo1905 --- DESIGN_CHOICES.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ 2 files changed, 106 insertions(+) create mode 100644 DESIGN_CHOICES.md diff --git a/DESIGN_CHOICES.md b/DESIGN_CHOICES.md new file mode 100644 index 0000000..873827a --- /dev/null +++ b/DESIGN_CHOICES.md @@ -0,0 +1,102 @@ +# 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”. + +## 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 +``` + +### 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. + +## 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` + diff --git a/README.md b/README.md index 0e9328b..8f91780 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ java -cp ./json-java21/target/java.util.json-*.jar:./json-java21/target/test-cla *Replace `*` with the actual version number from the JAR filename.* +## Design notes + +- **Numeric handling (JsonNumber, BigDecimal/BigInteger)**: see `DESIGN_CHOICES.md` + ## API Overview The API provides immutable JSON value types: From 2e222bd53f21b1ca30d85ecfdeca1377c3a33099 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 15:31:42 +0000 Subject: [PATCH 2/7] Issue #119 Add runnable numeric design choice examples Co-authored-by: simbo1905 --- .../json/DesignChoicesNumberExamplesTest.java | 76 +++++++++++++++++++ .../json/examples/DesignChoicesExamples.java | 67 ++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java create mode 100644 json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java diff --git a/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java b/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java new file mode 100644 index 0000000..4e1133e --- /dev/null +++ b/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java @@ -0,0 +1,76 @@ +package jdk.sandbox.java.util.json; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; + +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 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"); + + var thousand = new BigDecimal("1000"); + + var plain = JsonNumber.of(thousand.toPlainString()); + assertThat(plain.toString()).isEqualTo("1000"); + + var scientific = JsonNumber.of(new BigDecimal("1E+3").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())); + } +} + diff --git a/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java new file mode 100644 index 0000000..c9276fd --- /dev/null +++ b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java @@ -0,0 +1,67 @@ +package jdk.sandbox.java.util.json.examples; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonNumber; + +import java.math.BigDecimal; +import java.math.BigInteger; + +/** + * Standalone examples demonstrating numeric design choices. + * + *

+ * This file mirrors the examples in {@code DESIGN_CHOICES.md}. + */ +public final class DesignChoicesExamples { + + public static void main(String[] args) { + System.out.println("=== Numeric design choices examples ===\n"); + + parseToBigDecimalLossless(); + parseToBigIntegerExact(); + bigDecimalToJsonNumberChooseTextPolicy(); + lexicalPreservationNotNormalization(); + + System.out.println("\n=== All examples completed successfully! ==="); + } + + public static BigDecimal parseToBigDecimalLossless() { + var n = (JsonNumber) Json.parse("3.141592653589793238462643383279"); + var bd = new BigDecimal(n.toString()); + System.out.println("lossless BigDecimal: " + bd.toPlainString()); + return bd; + } + + public static BigInteger parseToBigIntegerExact() { + var n = (JsonNumber) Json.parse("1.23e2"); + var bi = new BigDecimal(n.toString()).toBigIntegerExact(); + System.out.println("exact BigInteger: " + bi); + return bi; + } + + public static JsonNumber bigDecimalToJsonNumberChooseTextPolicy() { + var bd = new BigDecimal("1000"); + + var plain = JsonNumber.of(bd.toPlainString()); + System.out.println("BigDecimal.toPlainString() -> JsonNumber: " + plain); + + var scientific = JsonNumber.of(new BigDecimal("1E+3").toString()); + System.out.println("BigDecimal.toString() -> JsonNumber: " + scientific); + + return plain; + } + + public static boolean lexicalPreservationNotNormalization() { + var a = (JsonNumber) Json.parse("1e2"); + var b = (JsonNumber) Json.parse("100"); + + System.out.println("a.toString(): " + a); + System.out.println("b.toString(): " + b); + System.out.println("same numeric value? " + new BigDecimal(a.toString()).compareTo(new BigDecimal(b.toString()))); + + return a.toString().equals(b.toString()); + } + + private DesignChoicesExamples() {} +} + From 1196242cde53f46b99170f19c3de092c8fb82d79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 16:01:54 +0000 Subject: [PATCH 3/7] Issue #119 Clarify BigDecimal text-policy examples Co-authored-by: simbo1905 --- .../java/util/json/DesignChoicesNumberExamplesTest.java | 9 ++++++--- .../java/util/json/examples/DesignChoicesExamples.java | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java b/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java index 4e1133e..b56894e 100644 --- a/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java +++ b/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java @@ -49,12 +49,15 @@ void parseExponentFormToBigIntegerExactWorks() { void bigDecimalToJsonNumberRequiresChoosingATextPolicy() { LOGGER.info("Executing bigDecimalToJsonNumberRequiresChoosingATextPolicy"); - var thousand = new BigDecimal("1000"); + // Using toPlainString() for a plain number representation + var bdPlain = new BigDecimal("1000"); - var plain = JsonNumber.of(thousand.toPlainString()); + var plain = JsonNumber.of(bdPlain.toPlainString()); assertThat(plain.toString()).isEqualTo("1000"); - var scientific = JsonNumber.of(new BigDecimal("1E+3").toString()); + // 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"); } diff --git a/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java index c9276fd..17cae15 100644 --- a/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java +++ b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java @@ -40,12 +40,15 @@ public static BigInteger parseToBigIntegerExact() { } public static JsonNumber bigDecimalToJsonNumberChooseTextPolicy() { - var bd = new BigDecimal("1000"); + // Example with toPlainString() to avoid scientific notation. + var bdPlain = new BigDecimal("1000"); - var plain = JsonNumber.of(bd.toPlainString()); + var plain = JsonNumber.of(bdPlain.toPlainString()); System.out.println("BigDecimal.toPlainString() -> JsonNumber: " + plain); - var scientific = JsonNumber.of(new BigDecimal("1E+3").toString()); + // Example with toString(), which may use scientific notation. + var bdScientific = new BigDecimal("1E+3"); + var scientific = JsonNumber.of(bdScientific.toString()); System.out.println("BigDecimal.toString() -> JsonNumber: " + scientific); return plain; From 3f62ed2315c15e1f0c956595a096a943c66dfc4c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 16:06:45 +0000 Subject: [PATCH 4/7] Issue #119 Add counter examples and native mapping guidance Co-authored-by: simbo1905 --- DESIGN_CHOICES.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/DESIGN_CHOICES.md b/DESIGN_CHOICES.md index 873827a..6587d20 100644 --- a/DESIGN_CHOICES.md +++ b/DESIGN_CHOICES.md @@ -13,6 +13,24 @@ During the last upstream sync, those entry points were removed. **That is consis 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* @@ -58,6 +76,28 @@ 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 @@ -93,6 +133,37 @@ 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: From 0903da3ef8dc89f80f43c7d3d838737791bf3359 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 16:09:23 +0000 Subject: [PATCH 5/7] Issue #119 Add counterexamples and native mapping demo Co-authored-by: simbo1905 --- .../json/DesignChoicesNumberExamplesTest.java | 42 ++++++++++ .../json/examples/DesignChoicesExamples.java | 83 +++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java b/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java index b56894e..bed71e8 100644 --- a/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java +++ b/json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java @@ -4,9 +4,11 @@ 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()); @@ -35,6 +37,24 @@ void convertingToDoubleIsPotentiallyLossy() { 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"); @@ -75,5 +95,27 @@ void lexicalPreservationDiffersFromNumericNormalization() { // 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) nativeValue; + + assertThat(map.get("smallInt")).isEqualTo(42L); + assertThat(map.get("decimal")).isInstanceOf(BigDecimal.class); + assertThat((BigDecimal) map.get("decimal")).isEqualByComparingTo(new BigDecimal("5.5")); + } } diff --git a/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java index 17cae15..73130e2 100644 --- a/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java +++ b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java @@ -1,10 +1,19 @@ package jdk.sandbox.java.util.json.examples; import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonAssertionException; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonBoolean; import jdk.sandbox.java.util.json.JsonNumber; +import jdk.sandbox.java.util.json.JsonNull; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; import java.math.BigDecimal; import java.math.BigInteger; +import java.util.Map; +import java.util.stream.Collectors; /** * Standalone examples demonstrating numeric design choices. @@ -21,6 +30,8 @@ public static void main(String[] args) { parseToBigIntegerExact(); bigDecimalToJsonNumberChooseTextPolicy(); lexicalPreservationNotNormalization(); + counterExamples(); + mappingToNativeTypesWithPatternMatching(); System.out.println("\n=== All examples completed successfully! ==="); } @@ -65,6 +76,78 @@ public static boolean lexicalPreservationNotNormalization() { return a.toString().equals(b.toString()); } + public static void counterExamples() { + System.out.println("Counter-examples"); + System.out.println("----------------"); + + // 1) Converting a non-integral JSON number to long throws. + try { + var nonIntegral = (JsonNumber) Json.parse("5.5"); + System.out.println("nonIntegral.toString(): " + nonIntegral); + System.out.println("nonIntegral.toLong(): " + nonIntegral.toLong()); + } catch (JsonAssertionException e) { + System.out.println("Expected toLong() failure: " + e.getMessage()); + } + + // 2) Converting an out-of-range JSON number to double throws. + try { + var tooBig = (JsonNumber) Json.parse("1e309"); + System.out.println("tooBig.toString(): " + tooBig); + System.out.println("tooBig.toDouble(): " + tooBig.toDouble()); + } catch (JsonAssertionException e) { + System.out.println("Expected toDouble() failure: " + e.getMessage()); + } + + // 3) Converting to double can be lossy even when it does not throw. + var highPrecision = (JsonNumber) Json.parse("3.141592653589793238462643383279"); + var lossless = new BigDecimal(highPrecision.toString()); + var lossy = new BigDecimal(Double.toString(highPrecision.toDouble())); + System.out.println("lossless (BigDecimal): " + lossless.toPlainString()); + System.out.println("lossy (double->BD): " + lossy.toPlainString()); + System.out.println("lossless equals lossy? " + (lossless.compareTo(lossy) == 0)); + System.out.println(); + } + + public 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(DesignChoicesExamples::toNative).toList(); + case JsonObject o -> o.members().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> toNative(e.getValue()))); + }; + } + + public static Object mappingToNativeTypesWithPatternMatching() { + System.out.println("Mapping to native types (pattern matching)"); + System.out.println("------------------------------------------"); + + JsonValue json = Json.parse(""" + { + "smallInt": 42, + "decimal": 5.5, + "huge": 3.141592653589793238462643383279, + "flag": true, + "name": "Ada", + "items": [1, 2, 3] + } + """); + + Object nativeValue = toNative(json); + System.out.println("native class: " + nativeValue.getClass().getName()); + System.out.println("native value: " + nativeValue); + System.out.println(); + return nativeValue; + } + private DesignChoicesExamples() {} } From 07c05456ab3f4aae72bfa7fa3e43de238cf08e74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 17:09:55 +0000 Subject: [PATCH 6/7] Issue #119 Fold numeric design notes into README Co-authored-by: simbo1905 --- DESIGN_CHOICES.md | 173 ------------------ README.md | 9 +- .../json/examples/DesignChoicesExamples.java | 2 +- 3 files changed, 8 insertions(+), 176 deletions(-) delete mode 100644 DESIGN_CHOICES.md diff --git a/DESIGN_CHOICES.md b/DESIGN_CHOICES.md deleted file mode 100644 index 6587d20..0000000 --- a/DESIGN_CHOICES.md +++ /dev/null @@ -1,173 +0,0 @@ -# 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` - diff --git a/README.md b/README.md index 8f91780..93499ac 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,14 @@ java -cp ./json-java21/target/java.util.json-*.jar:./json-java21/target/test-cla *Replace `*` with the actual version number from the JAR filename.* -## Design notes +## Numeric handling (JsonNumber and native Java numbers) -- **Numeric handling (JsonNumber, BigDecimal/BigInteger)**: see `DESIGN_CHOICES.md` +Prior versions of this backport included convenience entry points for building JSON numbers from `BigDecimal` / `BigInteger`. Upstream has moved away from that shape: `JsonNumber` preserves the JSON number text and you convert explicitly to native Java numeric types depending on your policy (lossless via `toString()` + `BigDecimal`, or range/precision-limited via `toLong()` / `toDouble()`). + +For runnable examples (including counter-examples where conversions throw or lose precision, plus a pattern-matching “map to native types” helper), see: + +- `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` ## API Overview diff --git a/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java index 73130e2..436df70 100644 --- a/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java +++ b/json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java @@ -19,7 +19,7 @@ * Standalone examples demonstrating numeric design choices. * *

- * This file mirrors the examples in {@code DESIGN_CHOICES.md}. + * See the repository README for context and pointers. */ public final class DesignChoicesExamples { From 46da612a0936c54ff1271d76d5b2d9b0e4adb9ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 17:10:08 +0000 Subject: [PATCH 7/7] Issue #119 Update CI expected test total Co-authored-by: simbo1905 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96e5cf7..af21fa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: for k in totals: totals[k]+=int(r.get(k,'0')) except Exception: pass - exp_tests=511 + exp_tests=519 exp_skipped=0 if totals['tests']!=exp_tests or totals['skipped']!=exp_skipped: print(f"Unexpected test totals: {totals} != expected tests={exp_tests}, skipped={exp_skipped}")