Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions DESIGN_CHOICES.md
Original file line number Diff line number Diff line change
@@ -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`

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Comment on lines +70 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic in this test can be made clearer and more consistent. The first part of the test defines a BigDecimal variable thousand, but the second part creates a BigDecimal inline. 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.

        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()));
}
}

Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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;
}
Comment on lines +53 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This example method can be made clearer and more consistent. The first part defines a BigDecimal variable bd, but the second part creates a BigDecimal inline. To improve readability and better illustrate the different text policies, it would be beneficial to use separate, descriptively named variables for both the "plain" and "scientific" cases.

    public static JsonNumber bigDecimalToJsonNumberChooseTextPolicy() {
        // Example with toPlainString() to avoid scientific notation.
        var bdPlain = new BigDecimal("1000");
        var plain = JsonNumber.of(bdPlain.toPlainString());
        System.out.println("BigDecimal.toPlainString() -> JsonNumber: " + plain);

        // 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;
    }


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() {}
}

Loading