From cf00261e6d0e8f2ba212055423dad238d8f5cb4a Mon Sep 17 00:00:00 2001 From: Denis Stepanov Date: Thu, 1 Feb 2024 13:02:19 +0100 Subject: [PATCH] Support unwrapping arrays for `JsonNode` decoder (#747) * Support unwrapping arrays for `JsonNode` decoder * Add since * Remove unneeded methods * Update serde-oracle-jdbc-json/src/main/java/io/micronaut/serde/oracle/jdbc/json/AbstractOracleJdbcJsonObjectMapper.java Co-authored-by: Jonas Konrad --------- Co-authored-by: Jonas Konrad --- serde-jsonp/build.gradle.kts | 1 + .../serde/json/stream/nodes/RootConfig.java | 91 +++++++++++++++++ .../json/stream/nodes/RootConfigSpec.groovy | 27 +++++ .../src/test/resources/application-test.yml | 14 +++ .../json/OracleJdbcJsonParserDecoder.java | 2 +- .../serde/support/util/JsonNodeDecoder.java | 99 +++++++++++++++++++ .../AbstractBasicSerdeCompileSpec.groovy | 67 +++++++++++++ 7 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfig.java create mode 100644 serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfigSpec.groovy create mode 100644 serde-jsonp/src/test/resources/application-test.yml diff --git a/serde-jsonp/build.gradle.kts b/serde-jsonp/build.gradle.kts index 81bc88e0d..a6851ed54 100644 --- a/serde-jsonp/build.gradle.kts +++ b/serde-jsonp/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { testCompileOnly(mn.micronaut.inject.groovy) testImplementation(mnTest.micronaut.test.spock) testImplementation(mnReactor.micronaut.reactor) + testRuntimeOnly("org.yaml:snakeyaml") } tasks { diff --git a/serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfig.java b/serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfig.java new file mode 100644 index 000000000..c3e88d6da --- /dev/null +++ b/serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfig.java @@ -0,0 +1,91 @@ +package io.micronaut.serde.json.stream.nodes; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.core.convert.format.MapFormat; +import io.micronaut.core.naming.conventions.StringConvention; +import io.micronaut.serde.annotation.Serdeable; + +import java.util.List; +import java.util.Map; + +@ConfigurationProperties("example.root.config") +@Serdeable +public class RootConfig { + private String someValue; + private NestedConfig someNested = new NestedConfig(); + private List someListNested = List.of(); + @MapFormat(keyFormat = StringConvention.RAW) + private Map someRawNested = Map.of(); + + public String getSomeValue() { + return someValue; + } + + public void setSomeValue(String someValue) { + this.someValue = someValue; + } + + public NestedConfig getSomeNested() { + return someNested; + } + + public void setSomeNested(NestedConfig someNested) { + this.someNested = someNested; + } + + public List getSomeListNested() { + return someListNested; + } + + public void setSomeListNested(List someListNested) { + this.someListNested = someListNested; + } + + public Map getSomeRawNested() { + return someRawNested; + } + + public void setSomeRawNested(Map someRawNested) { + this.someRawNested = someRawNested; + } + + @Serdeable + @ConfigurationProperties("some-nested") + public static class NestedConfig { + private String nestedValue; + + public String getNestedValue() { + return nestedValue; + } + + public void setNestedValue(String nestedValue) { + this.nestedValue = nestedValue; + } + } + + @Serdeable + public static class NestedListConfig { + private String nestedListValue; + + public String getNestedListValue() { + return nestedListValue; + } + + public void setNestedListValue(String nestedListValue) { + this.nestedListValue = nestedListValue; + } + } + + @Serdeable + public static class RawNestedConfig { + private String rawNestedValue; + + public String getRawNestedValue() { + return rawNestedValue; + } + + public void setRawNestedValue(String rawNestedValue) { + this.rawNestedValue = rawNestedValue; + } + } +} diff --git a/serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfigSpec.groovy b/serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfigSpec.groovy new file mode 100644 index 000000000..26487e612 --- /dev/null +++ b/serde-jsonp/src/test/groovy/io/micronaut/serde/json/stream/nodes/RootConfigSpec.groovy @@ -0,0 +1,27 @@ +package io.micronaut.serde.json.stream.nodes + +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +@MicronautTest +class RootConfigSpec extends Specification { + + @Inject + RootConfig rootConfig + + void "test config injection"() { + expect: + rootConfig.someValue == "foo" + rootConfig.someNested + rootConfig.someNested.nestedValue == "bar" + rootConfig.someListNested + rootConfig.someListNested.size() == 2 + rootConfig.someListNested[0].nestedListValue == "baz1" + rootConfig.someListNested[1].nestedListValue == "baz2" + rootConfig.someRawNested + rootConfig.someRawNested.size() == 2 + rootConfig.someRawNested.get('abc_def').rawNestedValue == "abc123" + rootConfig.someRawNested.get('def_ghi').rawNestedValue == "def456" + } +} diff --git a/serde-jsonp/src/test/resources/application-test.yml b/serde-jsonp/src/test/resources/application-test.yml new file mode 100644 index 000000000..92e1fa713 --- /dev/null +++ b/serde-jsonp/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +example: + root: + config: + some-value: "foo" + some-nested: + nested-value: "bar" + some-list-nested: + - nestedListValue: "baz1" + - nestedListValue: "baz2" + some-raw-nested: + abc_def: + raw-nested-value: "abc123" + def_ghi: + raw-nested-value: "def456" \ No newline at end of file diff --git a/serde-oracle-jdbc-json/src/main/java/io/micronaut/serde/oracle/jdbc/json/OracleJdbcJsonParserDecoder.java b/serde-oracle-jdbc-json/src/main/java/io/micronaut/serde/oracle/jdbc/json/OracleJdbcJsonParserDecoder.java index 7160173dc..39e795476 100644 --- a/serde-oracle-jdbc-json/src/main/java/io/micronaut/serde/oracle/jdbc/json/OracleJdbcJsonParserDecoder.java +++ b/serde-oracle-jdbc-json/src/main/java/io/micronaut/serde/oracle/jdbc/json/OracleJdbcJsonParserDecoder.java @@ -142,7 +142,7 @@ protected BigDecimal getBigDecimal() { @Override protected Number getBestNumber() { return switch (currentEvent) { - case VALUE_DECIMAL -> jsonParser.getLong(); + case VALUE_DECIMAL -> jsonParser.getBigDecimal(); case VALUE_DOUBLE -> jsonParser.getDouble(); case VALUE_FLOAT -> jsonParser.getFloat(); default -> diff --git a/serde-support/src/main/java/io/micronaut/serde/support/util/JsonNodeDecoder.java b/serde-support/src/main/java/io/micronaut/serde/support/util/JsonNodeDecoder.java index 0a6ecfd3b..a2c253297 100644 --- a/serde-support/src/main/java/io/micronaut/serde/support/util/JsonNodeDecoder.java +++ b/serde-support/src/main/java/io/micronaut/serde/support/util/JsonNodeDecoder.java @@ -77,6 +77,15 @@ public String decodeString() throws IOException { if (peeked.isString()) { skipValue(); return peeked.getStringValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.STRING)) { + String unwrapped = decoder.decodeString(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one string, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a string", toArbitrary(peeked)); } @@ -88,6 +97,15 @@ public boolean decodeBoolean() throws IOException { if (peeked.isBoolean()) { skipValue(); return peeked.getBooleanValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.BOOLEAN)) { + boolean unwrapped = decoder.decodeBoolean(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one boolean, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a boolean", toArbitrary(peeked)); } @@ -99,6 +117,15 @@ public byte decodeByte() throws IOException { if (peeked.isNumber()) { skipValue(); return (byte) peeked.getIntValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.BYTE)) { + byte unwrapped = decoder.decodeByte(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one byte, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -110,6 +137,15 @@ public short decodeShort() throws IOException { if (peeked.isNumber()) { skipValue(); return (short) peeked.getIntValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.SHORT)) { + short unwrapped = decoder.decodeShort(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one short, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -121,6 +157,15 @@ public char decodeChar() throws IOException { if (peeked.isNumber()) { skipValue(); return (char) peeked.getIntValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.CHAR)) { + char unwrapped = decoder.decodeChar(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one char, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -132,6 +177,15 @@ public int decodeInt() throws IOException { if (peeked.isNumber()) { skipValue(); return peeked.getIntValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.INT)) { + int unwrapped = decoder.decodeInt(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one int, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -143,6 +197,15 @@ public long decodeLong() throws IOException { if (peeked.isNumber()) { skipValue(); return peeked.getLongValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.LONG)) { + long unwrapped = decoder.decodeLong(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one long, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -154,6 +217,15 @@ public float decodeFloat() throws IOException { if (peeked.isNumber()) { skipValue(); return peeked.getFloatValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.FLOAT)) { + float unwrapped = decoder.decodeFloat(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one float, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -165,6 +237,15 @@ public double decodeDouble() throws IOException { if (peeked.isNumber()) { skipValue(); return peeked.getDoubleValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray(Argument.DOUBLE)) { + double unwrapped = decoder.decodeDouble(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one double, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -176,6 +257,15 @@ public BigInteger decodeBigInteger() throws IOException { if (peeked.isNumber()) { skipValue(); return peeked.getBigIntegerValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray()) { + BigInteger unwrapped = decoder.decodeBigInteger(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one BigInteger, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } @@ -187,6 +277,15 @@ public BigDecimal decodeBigDecimal() throws IOException { if (peeked.isNumber()) { skipValue(); return peeked.getBigDecimalValue(); + } else if (peeked.isArray()) { + try (Decoder decoder = decodeArray()) { + BigDecimal unwrapped = decoder.decodeBigDecimal(); + if (decoder.hasNextArrayValue()) { + throw createDeserializationException("Expected one BigDecimal, but got array of multiple values", null); + } else { + return unwrapped; + } + } } else { throw createDeserializationException("Not a number", toArbitrary(peeked)); } diff --git a/serde-tck/src/main/groovy/io/micronaut/serde/AbstractBasicSerdeCompileSpec.groovy b/serde-tck/src/main/groovy/io/micronaut/serde/AbstractBasicSerdeCompileSpec.groovy index 9dbf1310f..0783b2586 100644 --- a/serde-tck/src/main/groovy/io/micronaut/serde/AbstractBasicSerdeCompileSpec.groovy +++ b/serde-tck/src/main/groovy/io/micronaut/serde/AbstractBasicSerdeCompileSpec.groovy @@ -16,6 +16,7 @@ package io.micronaut.serde import io.micronaut.http.HttpStatus +import io.micronaut.json.tree.JsonNode import spock.lang.Unroll import java.nio.charset.Charset @@ -93,6 +94,72 @@ class Test { Locale | [value: Locale.CANADA_FRENCH] | '{"value":"fr-CA"}' } + @Unroll + void "test unwrap basic type #type"() { + given: + def context = buildContext('test.Test', """ +package test; + +import io.micronaut.serde.annotation.Serdeable; + +@Serdeable +class Test { + private $type.name value; + public void setValue($type.name value) { + this.value = value; + } + public $type.name getValue() { + return value; + } +} +""", data) + expect: + def read = jsonMapper.readValue(jsonAsBytes(result), typeUnderTest) + typeUnderTest.type.isInstance(read) + read.value == data.value + + and: + def node = jsonMapper.readValue(jsonAsBytes(result), JsonNode) + def read2 = jsonMapper.readValueFromTree(node, typeUnderTest) + typeUnderTest.type.isInstance(read2) + read2.value == data.value + + cleanup: + context?.close() + + where: + type | data | result + BigDecimal | [value: 10.1] | '{"value":[10.1]}' + BigInteger | [value: BigInteger.valueOf(10l)] | '{"value":[10]}' + String | [value: "Test"] | '{"value":["Test"]}' + boolean | [value: true] | '{"value":[true]}' + byte | [value: 10] | '{"value":[10]}' + short | [value: 10] | '{"value":[10]}' + int | [value: 10] | '{"value":[10]}' + long | [value: 10] | '{"value":[10]}' + double | [value: 10.1d] | '{"value":[10.1]}' + float | [value: 10.1f] | '{"value":[10.1]}' + char | [value: 'a' as char] | '{"value":[97]}' + //wrappers + Boolean | [value: true] | '{"value":[true]}' + Byte | [value: 10] | '{"value":[10]}' + Short | [value: 10] | '{"value":[10]}' + Integer | [value: 10] | '{"value":[10]}' + Long | [value: 10] | '{"value":[10]}' + Double | [value: 10.1d] | '{"value":[10.1]}' + Float | [value: 10.1f] | '{"value":[10.1]}' + Character | [value: 'a' as char] | '{"value":[97]}' + HttpStatus | [value: HttpStatus.ACCEPTED] | '{"value":["ACCEPTED"]}' + CharSequence | [value: "Xyz"] | '{"value":["Xyz"]}' + + // other common classes + URI | [value: URI.create("https://foo.com")] | '{"value":["https://foo.com"]}' + URL | [value: new URL("https://foo.com")] | '{"value":["https://foo.com"]}' + Charset | [value: StandardCharsets.UTF_8] | '{"value":["UTF-8"]}' + TimeZone | [value: TimeZone.getTimeZone("GMT")] | '{"value":["GMT"]}' + Locale | [value: Locale.CANADA_FRENCH] | '{"value":["fr-CA"]}' + } + @Unroll void "test basic type #type missing value"() { given: