diff --git a/README.adoc b/README.adoc index e0808b391..7c968d589 100644 --- a/README.adoc +++ b/README.adoc @@ -743,6 +743,26 @@ class Data implements Marshallable { } ---- +== Wire type implementation details + +=== JSONWire + +==== Handling of special floating point values such as Double.NaN + +JSONWire will serialize and deserialize special floating point values such as `Double.NaN` to and from valid JSON string literals in accordance +with the following table: + +[cols="2*", options="header"] +|================================================= +| Java primitive value | String representation +| Double.NaN | "NaN" +| Double.POSITIVE_INFINITY | "Infinity" +| Double.NEGATIVE_INFINITY | "-Infinity" +| Float.NaN | "NaN" +| Float.POSITIVE_INFINITY | "Infinity" +| Float.NEGATIVE_INFINITY | "-Infinity" +|================================================= + == LongConverter Provides an abstraction for converting between long values and their string representations, diff --git a/src/main/java/net/openhft/chronicle/wire/JSONWire.java b/src/main/java/net/openhft/chronicle/wire/JSONWire.java index 795a5abef..0ccbc9107 100644 --- a/src/main/java/net/openhft/chronicle/wire/JSONWire.java +++ b/src/main/java/net/openhft/chronicle/wire/JSONWire.java @@ -955,14 +955,30 @@ protected void fieldValueSeperator() { public void writeComment(@NotNull CharSequence s) { } + /** + * Write a special double value (e.g. NaN) as a string to the given bytes. + * + * @param bytes The bytes to append the stringified double value to + * @param value The double value to convert to a string + */ @Override - protected String doubleToString(double d) { - return Double.isNaN(d) ? "null" : super.doubleToString(d); + protected void writeSpecialDoubleValueToBytes(Bytes bytes, double value) { + bytes.append('"'); + bytes.append(Double.toString(value)); + bytes.append('"'); } + /** + * Write a special double value (e.g. NaN) as a string to the given bytes. + * + * @param bytes The bytes to append the stringified double value to + * @param value The double value to convert to a string + */ @Override - protected String floatToString(float f) { - return Float.isNaN(f) ? "null" : super.floatToString(f); + protected void writeSpecialFloatValueToBytes(Bytes bytes, float value) { + bytes.append('"'); + bytes.append(Float.toString(value)); + bytes.append('"'); } @NotNull diff --git a/src/main/java/net/openhft/chronicle/wire/YamlWireOut.java b/src/main/java/net/openhft/chronicle/wire/YamlWireOut.java index ea251ea2a..2e42132c2 100644 --- a/src/main/java/net/openhft/chronicle/wire/YamlWireOut.java +++ b/src/main/java/net/openhft/chronicle/wire/YamlWireOut.java @@ -997,7 +997,7 @@ public T float32(float f) { if (af == 0 || (af >= 1e-3 && af < 1e6)) bytes.append(f); else - bytes.append(floatToString(f)); + writeSpecialFloatValueToBytes(bytes, f); elementSeparator(); return wireOut(); @@ -1037,7 +1037,7 @@ else if (ad7 < 1e4) bytes.append(d); } } else { - bytes.append(doubleToString(d)); + writeSpecialDoubleValueToBytes(bytes, d); } elementSeparator(); @@ -1045,23 +1045,19 @@ else if (ad7 < 1e4) } /** - * Converts the provided double value to its corresponding String representation. - * - * @param d The double value to convert. - * @return The String representation of the provided double value. + * Writes a special double value, e.g. NaN, to bytes in the context of Yaml Wire. For now this + * remains as an unquoted string representation. */ - protected String doubleToString(double d) { - return Double.toString(d); + protected void writeSpecialDoubleValueToBytes(Bytes bytes, double value) { + bytes.append(Double.toString(value)); } /** - * Converts the provided float value to its corresponding String representation. - * - * @param f The float value to convert. - * @return The String representation of the provided float value. + * Writes a special double value, e.g. NaN, to bytes in the context of Yaml Wire. For now this + * remains as an unquoted string representation. */ - protected String floatToString(float f) { - return Float.toString(f); + protected void writeSpecialFloatValueToBytes(Bytes bytes, float value) { + bytes.append(Float.toString(value)); } @NotNull diff --git a/src/test/java/net/openhft/chronicle/wire/JSONNanTest.java b/src/test/java/net/openhft/chronicle/wire/JSONNanTest.java index 4266f4224..af2dc94ff 100644 --- a/src/test/java/net/openhft/chronicle/wire/JSONNanTest.java +++ b/src/test/java/net/openhft/chronicle/wire/JSONNanTest.java @@ -42,7 +42,7 @@ public void writeNaNs() { wire.write().marshallable(value); // Assert that the wire content represents the NaN as null - Assert.assertEquals("\"\":{\"value\":0.0,\"value1\":null,\"value2\":0,\"field\":\"text\"}", wire.toString()); + Assert.assertEquals("\"\":{\"value\":0.0,\"value1\":\"NaN\",\"value2\":0,\"field\":\"text\"}", wire.toString()); } finally { // Release the byte buffer resources b.releaseLast(); diff --git a/src/test/java/net/openhft/chronicle/wire/JsonWireDoubleAndFloatSpecialValuesAcceptanceTests.java b/src/test/java/net/openhft/chronicle/wire/JsonWireDoubleAndFloatSpecialValuesAcceptanceTests.java new file mode 100644 index 000000000..a93ea0a52 --- /dev/null +++ b/src/test/java/net/openhft/chronicle/wire/JsonWireDoubleAndFloatSpecialValuesAcceptanceTests.java @@ -0,0 +1,195 @@ +package net.openhft.chronicle.wire; + +import net.openhft.chronicle.bytes.Bytes; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.function.DoublePredicate; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Acceptance tests for JSON Wire Double and Float special value handling, e.g. NaN, Infinity. + *

+ * For implementation details please see: + *

+ */ +public class JsonWireDoubleAndFloatSpecialValuesAcceptanceTests { + + @ParameterizedTest + @MethodSource("doubleTestInputs") + void serialiseDoubleSpecialValues(DoubleTestInput doubleTestInput) { + assertEquals( + String.format("\"%s\"", doubleTestInput.expectedStringRepresentation), + toJson(doubleTestInput.inputValue), + "Expected correct representation for special value and for it to be quoted as a string literal" + ); + } + + @ParameterizedTest + @MethodSource("floatTestInputs") + void serialiseFloatSpecialValues(FloatTestInput floatTestInput) { + assertEquals( + String.format("\"%s\"", floatTestInput.expectedStringRepresentation), + toJson(floatTestInput.inputValue), + "Expected correct representation for special value and for it to be quoted as a string literal" + ); + } + + @ParameterizedTest + @MethodSource("doubleTestInputs") + void doubleRoundTrip(DoubleTestInput doubleTestInput) { + // Serialise an object to JSON and ensure its represented correctly + JSONWire inputWire = new JSONWire(); + inputWire.getValueOut().object(new DoubleDto(doubleTestInput.inputValue)); + String text = JSONWire.asText(inputWire); + assertEquals( + String.format("{\"value\":\"%s\"}", doubleTestInput.expectedStringRepresentation), + text, + "Expected JSON representation where special values are quoted string literals" + ); + + // Deserialize back to an object, ensure that the special value is retained + JSONWire outputWire = JSONWire.from(text); + DoubleDto object = outputWire.getValueIn().object(DoubleDto.class); + Assertions.assertNotNull(object); + Assertions.assertTrue(doubleTestInput.expectOutputDoubleToMatchThisPredicate.test(object.value)); + } + + @ParameterizedTest + @MethodSource("floatTestInputs") + void floatRoundTrip(FloatTestInput floatTestInput) { + // Serialise an object to JSON and ensure its represented correctly + JSONWire inputWire = new JSONWire(); + inputWire.getValueOut().object(new FloatDto(floatTestInput.inputValue)); + String text = JSONWire.asText(inputWire); + assertEquals( + String.format("{\"value\":\"%s\"}", floatTestInput.expectedStringRepresentation), + text, + "Expected JSON representation where special values are quoted string literals" + ); + + // Deserialize back to an object, ensure that the special value is retained + JSONWire outputWire = JSONWire.from(text); + FloatDto object = outputWire.getValueIn().object(FloatDto.class); + Assertions.assertNotNull(object); + Assertions.assertTrue(floatTestInput.expectOutputFloatToMatchThisPredicate.test(object.value)); + } + + private static Stream doubleTestInputs() { + return Stream.of( + new DoubleTestInput(Double.NaN, "NaN", Double::isNaN), + new DoubleTestInput(Double.NEGATIVE_INFINITY, "-Infinity", Double::isInfinite), + new DoubleTestInput(Double.POSITIVE_INFINITY, "Infinity", Double::isInfinite) + ); + } + + private static class DoubleTestInput { + private final double inputValue; + private final String expectedStringRepresentation; + + private final DoublePredicate expectOutputDoubleToMatchThisPredicate; + + private DoubleTestInput(double inputValue, + String expectedStringRepresentation, + DoublePredicate expectOutputDoubleToMatchThisPredicate) { + this.inputValue = inputValue; + this.expectedStringRepresentation = expectedStringRepresentation; + this.expectOutputDoubleToMatchThisPredicate = expectOutputDoubleToMatchThisPredicate; + } + + @Override + public String toString() { + return "DoubleTestInput{" + + "value=" + inputValue + + ", expectedRepresentation='" + expectedStringRepresentation + '\'' + + '}'; + } + + } + + private static Stream floatTestInputs() { + return Stream.of( + new FloatTestInput(Float.NaN, "NaN", Double::isNaN), + new FloatTestInput(Float.NEGATIVE_INFINITY, "-Infinity", Double::isInfinite), + new FloatTestInput(Float.POSITIVE_INFINITY, "Infinity", Double::isInfinite) + ); + } + + private static class FloatTestInput { + + private final float inputValue; + private final String expectedStringRepresentation; + private final Predicate expectOutputFloatToMatchThisPredicate; // No dedicated FloatPredicate in JDK + + private FloatTestInput(float inputValue, + String expectedStringRepresentation, + Predicate expectOutputFloatToMatchThisPredicate) { + this.inputValue = inputValue; + this.expectedStringRepresentation = expectedStringRepresentation; + this.expectOutputFloatToMatchThisPredicate = expectOutputFloatToMatchThisPredicate; + } + + @Override + public String toString() { + return "FloatTestInput{" + + "value=" + inputValue + + ", expectedRepresentation='" + expectedStringRepresentation + '\'' + + '}'; + } + + } + + /** + * Convert the double value to its JSON string representation. + */ + public String toJson(double value) { + JSONWire jsonWire = new JSONWire(); + jsonWire.getValueOut().object(value); + return JSONWire.asText(jsonWire); + } + + /** + * Convert the float value to its JSON string representation. + */ + public String toJson(float value) { + JSONWire jsonWire = new JSONWire(); + jsonWire.getValueOut().object(value); + return JSONWire.asText(jsonWire); + } + + /** + * Simple DTO for testing double serialise/deserialize to/from JSON. + */ + private static class DoubleDto extends SelfDescribingMarshallable { + + private final double value; + + private DoubleDto(double value) { + this.value = value; + } + } + + /** + * Simple DTO for testing float serialise/deserialize to/from JSON. + */ + private static class FloatDto extends SelfDescribingMarshallable { + + private final float value; + + private FloatDto(float value) { + this.value = value; + } + } + +}