diff --git a/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/IonParser.java b/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/IonParser.java index 706068237..1948b9fe3 100644 --- a/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/IonParser.java +++ b/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/IonParser.java @@ -53,6 +53,18 @@ public enum Feature implements FormatFeature // in 2.12 * @since 2.12.3 */ USE_NATIVE_TYPE_ID(true), + /** + * Whether to convert "null" to an IonValueNull (true); + * or leave as a java null (false) when deserializing. + *

+ * Enabled by default for backwards compatibility as that has been the behavior + * of `jackson-dataformat-ion` since 2.13. + * + * @see The Ion Specification + * + * @since 2.19.0 + */ + READ_NULL_AS_IONVALUE(true), ; final boolean _defaultState; @@ -563,6 +575,17 @@ private IonValue getIonValue() throws IOException { writer.writeValue(_reader); IonValue v = l.get(0); v.removeFromContainer(); + + if (!Feature.READ_NULL_AS_IONVALUE.enabledIn(_formatFeatures)) { + // 2025-04-11, seadbrane: The default is to read 'null' as an Ion Null object. + // However, there is no way to determine from the serialized ion data if a 'null' + // was an IonNullValue or a 'null' container type such as IonNullStruct or IonNullList. + // So if READ_NULL_AS_IONVALUE is disabled, then return 'null' if the _valueToken + // is 'null' and the Ion value read is not container type already. + if (v.isNullValue() && _valueToken == JsonToken.VALUE_NULL && !IonType.isContainer(v.getType())) { + return null; + } + } return v; } diff --git a/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializer.java b/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializer.java index 0835a989b..bd04adf7c 100644 --- a/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializer.java +++ b/ion/src/main/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializer.java @@ -16,21 +16,40 @@ import java.io.IOException; -import com.amazon.ion.*; - import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; import com.fasterxml.jackson.databind.util.AccessPattern; import com.fasterxml.jackson.dataformat.ion.IonParser; +import com.amazon.ion.*; /** * Deserializer that knows how to deserialize an IonValue. */ -class IonValueDeserializer extends JsonDeserializer { +class IonValueDeserializer extends JsonDeserializer implements ContextualDeserializer { + + private final JavaType _targetType; + + public IonValueDeserializer() { + this._targetType = null; + } + + public IonValueDeserializer(JavaType targetType) { + this._targetType = targetType; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { + JavaType contextualType = (property != null) + ? property.getType() + : ctxt.getContextualType(); // fallback + return new IonValueDeserializer(contextualType); + } @Override public IonValue deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + Object embeddedObject = jp.getEmbeddedObject(); if (embeddedObject instanceof IonValue) { return (IonValue) embeddedObject; @@ -62,17 +81,34 @@ public IonValue getNullValue(DeserializationContext ctxt) throws JsonMappingExce if (embeddedObj instanceof IonValue) { IonValue iv = (IonValue) embeddedObj; if (iv.isNullValue()) { + if (IonType.isContainer(iv.getType())) { + return iv; + } + IonType containerType = getIonContainerType(); + if (containerType != null) { + IonSystem ionSystem = ((IonParser) parser).getIonSystem(); + return ionSystem.newNull(containerType); + } return iv; } } } - return super.getNullValue(ctxt); } catch (IOException e) { throw JsonMappingException.from(ctxt, e.toString()); } } + private IonType getIonContainerType() { + if (_targetType != null) { + Class clazz = _targetType.getRawClass(); + if (IonStruct.class.isAssignableFrom(clazz)) return IonType.STRUCT; + if (IonList.class.isAssignableFrom(clazz)) return IonType.LIST; + if (IonSexp.class.isAssignableFrom(clazz)) return IonType.SEXP; + } + return null; + } + @Override public AccessPattern getNullAccessPattern() { return AccessPattern.DYNAMIC; diff --git a/ion/src/test/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializerTest.java b/ion/src/test/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializerTest.java index 7c97012e6..430cba2fa 100644 --- a/ion/src/test/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializerTest.java +++ b/ion/src/test/java/com/fasterxml/jackson/dataformat/ion/ionvalue/IonValueDeserializerTest.java @@ -1,17 +1,25 @@ package com.fasterxml.jackson.dataformat.ion.ionvalue; -import java.io.IOException; -import java.util.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import com.amazon.ion.IonSystem; +import com.amazon.ion.IonValue; +import com.amazon.ion.IonStruct; -import com.amazon.ion.*; -import com.amazon.ion.system.IonSystemBuilder; import org.junit.jupiter.api.Test; -import com.fasterxml.jackson.annotation.*; +import com.amazon.ion.system.IonSystemBuilder; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.util.AccessPattern; import com.fasterxml.jackson.dataformat.ion.IonObjectMapper; +import com.fasterxml.jackson.dataformat.ion.IonParser; -import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -65,7 +73,10 @@ static class IonValueData extends Data { } private static final IonSystem SYSTEM = IonSystemBuilder.standard().build(); - private static final IonValueMapper ION_VALUE_MAPPER = new IonValueMapper(SYSTEM, SNAKE_CASE); + private static final IonValueMapper ION_VALUE_MAPPER + = new IonValueMapper(SYSTEM, PropertyNamingStrategies.SNAKE_CASE); + private static final IonValueMapper ION_MAPPER_READ_NULL_DISABLED + = (IonValueMapper) new IonValueMapper(SYSTEM, PropertyNamingStrategies.SNAKE_CASE).disable(IonParser.Feature.READ_NULL_AS_IONVALUE); @Test public void shouldBeAbleToDeserialize() throws Exception { @@ -91,24 +102,48 @@ public void shouldBeAbleToDeserializeIncludingNullList() throws Exception { assertEquals(ion("null.list"), data.getAllData().get("c")); } + @Test + public void shouldBeAbleToDeserializeNullToIonNull() throws Exception { + verifyNullDeserialization("{c:null}", SYSTEM.newNull(), null); + } + @Test public void shouldBeAbleToDeserializeNullList() throws Exception { - IonValue ion = ion("{c:null.list}"); + verifyNullDeserialization("{c:null.list}", SYSTEM.newNullList()); + } - IonValueData data = ION_VALUE_MAPPER.readValue(ion, IonValueData.class); - assertEquals(1, data.getAllData().size()); - assertEquals(SYSTEM.newNullList(), data.getAllData().get("c")); - } @Test public void shouldBeAbleToDeserializeNullStruct() throws Exception { - IonValue ion = ion("{c:null.struct}"); + verifyNullDeserialization("{c:null.struct}", SYSTEM.newNullStruct()); + } - IonValueData data = ION_VALUE_MAPPER.readValue(ion, IonValueData.class); + @Test + public void shouldBeAbleToDeserializeNullSexp() throws Exception { + verifyNullDeserialization("{c:null.sexp}", SYSTEM.newNullSexp()); + } + + private void verifyNullDeserialization(String ionString, IonValue expected) throws Exception { + verifyNullDeserialization(ionString, expected, expected); + } + + private void verifyNullDeserialization(String ionString, IonValue expected, IonValue expectedReadNullDisabled) throws Exception { + verifyNullDeserialization(ION_VALUE_MAPPER, ionString, expected); + verifyNullDeserialization(ION_MAPPER_READ_NULL_DISABLED, ionString, expectedReadNullDisabled); + } + + private void verifyNullDeserialization(IonValueMapper mapper, String ionString, IonValue expected) throws Exception { + IonValueData data = mapper.readValue(ionString, IonValueData.class); + + assertEquals(1, data.getAllData().size()); + assertEquals(expected, data.getAllData().get("c")); + + IonValue ion = ion(ionString); + data = mapper.readValue(ion, IonValueData.class); assertEquals(1, data.getAllData().size()); - assertEquals(SYSTEM.newNullStruct(), data.getAllData().get("c")); + assertEquals(expected, data.getAllData().get("c")); } @Test @@ -154,6 +189,22 @@ public void shouldBeAbleToSerializeAndDeserializePojo() throws Exception { assertEquals(source, result); } + @Test + public void shouldBeAbleToSerializeAndDeserializeIonValueDataWithIncludeNonNull() throws Exception { + IonValueData source = new IonValueData(); + source.put("a", SYSTEM.newInt(1)); + source.put("b", SYSTEM.newNull()); + source.put("c", null); + IonValueMapper mapper = (IonValueMapper) ION_VALUE_MAPPER.copy().setSerializationInclusion(JsonInclude.Include.NON_NULL); + + String data = mapper.writeValueAsString(source); + assertEquals("{a:1,b:null}", data); + // Now remove the null element for the comparison below. + source.getAllData().remove("c"); + IonValueData result = mapper.readValue(data, IonValueData.class); + assertEquals(source, result); + } + @Test public void shouldBeAbleToSerializeAndDeserializeStringData() throws Exception { StringData source = new StringData(); @@ -162,7 +213,17 @@ public void shouldBeAbleToSerializeAndDeserializeStringData() throws Exception { IonValue data = ION_VALUE_MAPPER.writeValueAsIonValue(source); StringData result = ION_VALUE_MAPPER.parse(data, StringData.class); + assertEquals(source, result); + } + + @Test + public void shouldBeAbleToSerializeAndDeserializeStringDataAsString() throws Exception { + StringData source = new StringData(); + source.put("a", "1"); + source.put("b", null); + String data = ION_VALUE_MAPPER.writeValueAsString(source); + StringData result = ION_VALUE_MAPPER.readValue(data, StringData.class); assertEquals(source, result); } @@ -180,7 +241,7 @@ static class MyBean { } @Test - public void testWithMissingProperty() throws IOException + public void testWithMissingProperty() throws Exception { IonSystem ionSystem = IonSystemBuilder.standard().build(); IonObjectMapper ionObjectMapper = IonObjectMapper.builder(ionSystem) diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index 6348ee0a8..8dec9da09 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -381,3 +381,7 @@ Cormac Redmond (@credmond) Manuel Sugawara (@sugmanue) * Contributed #568: Improve ASCII decoding performance for `CBORParser` (2.19.0) + +Josh Curry (@seadbrane) + * Reported, contributed fix for #571: Unable to deserialize a pojo with IonStruct + (2.19.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 135d01caa..213be7c18 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -14,6 +14,9 @@ Active maintainers: === Releases === ------------------------------------------------------------------------ +#571: Unable to deserialize a pojo with IonStruct + (reported, fix contributed by Josh C) + 2.19.0-rc2 (07-Apr-2025) #300: (smile) Floats are encoded with sign extension while doubles without