diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 08e5800bf1..48666f3bf6 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -496,6 +496,10 @@ Martin Uhlen (@MartinUhlen) absent field same as explicit `null` [3.2.0] +Frederic Aubert (@f-aubert) + * Contributed #5745: Ability to change active JsonView on submodels (with `@JsonApplyView`) + [3.2.0] + David Nelson (@eatdrinksleepcode) * Reported #5814: Enum deserialization does not respect `JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_VALUES` override diff --git a/release-notes/VERSION b/release-notes/VERSION index fcd1e16fa8..ef01414e14 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -180,6 +180,8 @@ Versions: 3.x (for earlier see VERSION-2.x) #5734: `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` treats absent field same as explicit `null` (reported by Martin U) +#5745: Ability to change active JsonView on submodels (with `@JsonApplyView`) + (contributed by Frederic A) #5821: Fix dead code and side-effect bug in `BeanPropertyWriter.toString()` (fix by @pjfanning) #5851: Regression of `JsonTypeInfo.Id.MINIMAL_CLASS` in the 3.x branch diff --git a/src/main/java/tools/jackson/databind/introspect/AnnotationIntrospectorPair.java b/src/main/java/tools/jackson/databind/introspect/AnnotationIntrospectorPair.java index e09e04eac7..dfd1565280 100644 --- a/src/main/java/tools/jackson/databind/introspect/AnnotationIntrospectorPair.java +++ b/src/main/java/tools/jackson/databind/introspect/AnnotationIntrospectorPair.java @@ -380,10 +380,6 @@ public Class[] findViews(MapperConfig config, Annotated a) { @Override public Class findApplyView(MapperConfig config, Annotated a) { - /* Theoretically this could be trickier, if multiple introspectors - * return non-null entries. For now, though, we'll just consider - * first one to return non-null to win. - */ Class result = _primary.findApplyView(config, a); if (result == null) { result = _secondary.findApplyView(config, a); diff --git a/src/main/java/tools/jackson/databind/introspect/BeanPropertyDefinition.java b/src/main/java/tools/jackson/databind/introspect/BeanPropertyDefinition.java index 63993e2d38..fe2f278c1f 100644 --- a/src/main/java/tools/jackson/databind/introspect/BeanPropertyDefinition.java +++ b/src/main/java/tools/jackson/databind/introspect/BeanPropertyDefinition.java @@ -241,6 +241,23 @@ public AnnotatedMember getNonConstructorMutator() { */ public Class[] findViews() { return null; } + /** + * Method used to find an override view that should be activated when + * processing this property's value (and any nested values reached through + * it), as configured by {@code @JsonApplyView}. + *

+ * Unlike {@link #findViews()} (which lists views in which the property + * itself is included), this returns the view to make active while the + * value is being serialized. Special marker + * {@code JsonApplyView.NONE} indicates that view processing should be + * disabled (active view set to {@code null}) for the property and its subtree. + * + * @return Override view to apply, or {@code null} if no override is configured + * + * @since 3.2 + */ + public Class findApplyView() { return null; } + /** * Method used to find whether property is part of a bi-directional * reference. diff --git a/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java b/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java index ae6e44257f..cfbee9f688 100644 --- a/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java +++ b/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java @@ -826,6 +826,11 @@ public Class[] findViews() { return _annotationIntrospector.findViews(_config, getPrimaryMember()); } + @Override + public Class findApplyView() { + return _annotationIntrospector.findApplyView(_config, getPrimaryMember()); + } + @Override public AnnotationIntrospector.ReferenceProperty findReferenceType() { // 30-Mar-2017, tatu: Access lazily but retain information since it needs diff --git a/src/main/java/tools/jackson/databind/ser/BeanPropertyWriter.java b/src/main/java/tools/jackson/databind/ser/BeanPropertyWriter.java index 23bb83232f..99363c6582 100644 --- a/src/main/java/tools/jackson/databind/ser/BeanPropertyWriter.java +++ b/src/main/java/tools/jackson/databind/ser/BeanPropertyWriter.java @@ -6,6 +6,7 @@ import java.lang.reflect.Field; import java.util.HashMap; +import com.fasterxml.jackson.annotation.JsonApplyView; import com.fasterxml.jackson.annotation.JsonInclude; import tools.jackson.core.JacksonException; @@ -177,6 +178,13 @@ public class BeanPropertyWriter */ protected final Class[] _includeInViews; + /** + * View to apply for this property when applyView is available for the Bean. + * + * @since 3.2 + */ + protected final Class _applyView; + /** * Inclusion settings for this property, pre-computed in {@code PropertyBuilder} * by merging global defaults, type defaults, and property-level annotations, @@ -214,13 +222,31 @@ public BeanPropertyWriter(BeanPropertyDefinition propDef, { this(propDef, member, contextAnnotations, declaredType, ser, typeSer, serType, suppressNulls, suppressableValue, - includeInViews, null); + includeInViews, null, null); + } + + /** + * @deprecated Since 3.2 use {@link #BeanPropertyWriter(BeanPropertyDefinition, + * AnnotatedMember, Annotations, JavaType, ValueSerializer, TypeSerializer, + * JavaType, boolean, Object, Class[], JsonInclude.Value, Class)} instead. + */ + @Deprecated // @since 3.2 + public BeanPropertyWriter(BeanPropertyDefinition propDef, + AnnotatedMember member, Annotations contextAnnotations, + JavaType declaredType, + ValueSerializer ser, TypeSerializer typeSer, JavaType serType, + boolean suppressNulls, Object suppressableValue, + Class[] includeInViews, JsonInclude.Value inclusion) + { + this(propDef, member, contextAnnotations, declaredType, + ser, typeSer, serType, suppressNulls, suppressableValue, + includeInViews, inclusion, null); } /** - * Constructor with additional inclusion parameter. + * Constructor with additional inclusion and applyView parameter. * - * @since 3.1 + * @since 3.2 */ @SuppressWarnings("unchecked") public BeanPropertyWriter(BeanPropertyDefinition propDef, @@ -228,7 +254,7 @@ public BeanPropertyWriter(BeanPropertyDefinition propDef, JavaType declaredType, ValueSerializer ser, TypeSerializer typeSer, JavaType serType, boolean suppressNulls, Object suppressableValue, - Class[] includeInViews, JsonInclude.Value inclusion) + Class[] includeInViews, JsonInclude.Value inclusion, Class applyView) { super(propDef); _member = member; @@ -260,6 +286,7 @@ public BeanPropertyWriter(BeanPropertyDefinition propDef, // this will be resolved later on, unless nulls are to be suppressed _nullSerializer = null; _includeInViews = includeInViews; + _applyView = applyView; _inclusion = (inclusion == null) ? JsonInclude.Value.empty() : inclusion; } @@ -276,6 +303,7 @@ protected BeanPropertyWriter() { _name = null; _wrapperName = null; _includeInViews = null; + _applyView = null; _declaredType = null; _serializer = null; @@ -323,6 +351,7 @@ protected BeanPropertyWriter(BeanPropertyWriter base, PropertyName name) { _suppressNulls = base._suppressNulls; _suppressableValue = base._suppressableValue; _includeInViews = base._includeInViews; + _applyView = base._applyView; _typeSerializer = base._typeSerializer; _nonTrivialBaseType = base._nonTrivialBaseType; _inclusion = base._inclusion; @@ -347,6 +376,7 @@ protected BeanPropertyWriter(BeanPropertyWriter base, SerializedString name) { _suppressNulls = base._suppressNulls; _suppressableValue = base._suppressableValue; _includeInViews = base._includeInViews; + _applyView = base._applyView; _typeSerializer = base._typeSerializer; _nonTrivialBaseType = base._nonTrivialBaseType; _inclusion = base._inclusion; @@ -660,10 +690,12 @@ public void serializeAsProperty(Object bean, JsonGenerator g, SerializationConte } } g.writeName(_name); - if (_typeSerializer == null) { - ser.serialize(value, g, ctxt); + if (_applyView == null) { + _serialize(value, g, ctxt, ser); } else { - ser.serializeWithType(value, g, ctxt, _typeSerializer); + ValueSerializer actualSer = ser; + ctxt.withActiveView(_applyView != JsonApplyView.NONE.class ? _applyView : null, + () -> _serialize(value, g, ctxt, actualSer)); } } @@ -728,10 +760,13 @@ public void serializeAsElement(Object bean, JsonGenerator g, SerializationContex return; } } - if (_typeSerializer == null) { - ser.serialize(value, g, ctxt); + + if (_applyView == null) { + _serialize(value, g, ctxt, ser); } else { - ser.serializeWithType(value, g, ctxt, _typeSerializer); + ValueSerializer actualSer = ser; + ctxt.withActiveView(_applyView != JsonApplyView.NONE.class ? _applyView : null, + () -> _serialize(value, g, ctxt, actualSer)); } } @@ -753,6 +788,16 @@ public void serializeAsOmittedElement(Object bean, JsonGenerator g, } } + // @since 3.2 + private void _serialize(Object value, JsonGenerator g, SerializationContext ctxt, + ValueSerializer ser) { + if (_typeSerializer == null) { + ser.serialize(value, g, ctxt); + } else { + ser.serializeWithType(value, g, ctxt, _typeSerializer); + } + } + /* /********************************************************************** /* PropertyWriter methods (schema generation) diff --git a/src/main/java/tools/jackson/databind/ser/PropertyBuilder.java b/src/main/java/tools/jackson/databind/ser/PropertyBuilder.java index 02f08c0cd5..8e07f1f794 100644 --- a/src/main/java/tools/jackson/databind/ser/PropertyBuilder.java +++ b/src/main/java/tools/jackson/databind/ser/PropertyBuilder.java @@ -246,13 +246,15 @@ protected BeanPropertyWriter buildWriter(SerializationContext ctxt, if (views == null) { views = _beanDesc.findDefaultViews(); } + Class applyView = propDef.findApplyView(); + // [databind#1649]: Pass the computed inclusion value (which includes // contextual annotations) so BeanPropertyWriter can use it directly // instead of re-computing in findPropertyInclusion() BeanPropertyWriter bpw = _constructPropertyWriter(propDef, am, _beanDesc.getClassAnnotations(), declaredType, ser, typeSer, serializationType, suppressNulls, valueToSuppress, views, - inclV); + inclV, applyView); // How about custom null serializer? Object serDef = _annotationIntrospector.findNullSerializer(_config, am); @@ -285,12 +287,12 @@ protected BeanPropertyWriter _constructPropertyWriter(BeanPropertyDefinition pro JavaType declaredType, ValueSerializer ser, TypeSerializer typeSer, JavaType serType, boolean suppressNulls, Object suppressableValue, - Class[] includeInViews, JsonInclude.Value inclusion) + Class[] includeInViews, JsonInclude.Value inclusion, Class applyView) { return new BeanPropertyWriter(propDef, member, contextAnnotations, declaredType, ser, typeSer, serType, suppressNulls, suppressableValue, includeInViews, - inclusion); + inclusion, applyView); } /* diff --git a/src/test/java/tools/jackson/databind/views/ApplyViewSerializationTest.java b/src/test/java/tools/jackson/databind/views/ApplyViewSerializationTest.java new file mode 100644 index 0000000000..18e8f0abb7 --- /dev/null +++ b/src/test/java/tools/jackson/databind/views/ApplyViewSerializationTest.java @@ -0,0 +1,219 @@ +package tools.jackson.databind.views; + +import java.io.StringWriter; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonApplyView; +import com.fasterxml.jackson.annotation.JsonView; + +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.jupiter.api.Assertions.*; + +//[databind#5745] : allow overriding JsonView + +/** + * Unit tests for verifying JSON apply view functionality. + */ +public class ApplyViewSerializationTest extends DatabindTestUtil +{ + // Classes that represent views + static class ViewA { } + static class ViewAA extends ViewA { } + static class ViewB { } + + static class Bean + { + @JsonView(ViewA.class) + public String a = "1"; + + @JsonView({ViewAA.class, ViewB.class}) + public String aa = "2"; + + @JsonView(ViewB.class) + public String getB() { return "3"; } + } + + static class Bean2 { + + @JsonView(ViewA.class) + @JsonApplyView(ViewB.class) + public Bean beanWithApplyViewB = new Bean(); + + @JsonView(ViewA.class) + @JsonApplyView(JsonApplyView.NONE.class) + public Bean beanWithApplyNoneView = new Bean(); + } + + static class Bean3 { + + @JsonView(ViewA.class) + public Bean bean = new Bean(); + + @JsonView(ViewA.class) + public Bean2 bean2 = new Bean2(); + + @JsonView(ViewA.class) + @JsonApplyView(ViewB.class) + public Bean2 bean2WithApplyViewB = new Bean2(); + } + + // Used to verify that an inner @JsonApplyView overrides an outer one: + // outer writer activates ViewB; this field's @JsonView(ViewB) lets it through, + // and its @JsonApplyView(ViewA) switches the active view to ViewA for the + // nested Bean2 — without which both Bean2 fields (each @JsonView(ViewA)) + // would be filtered out by ViewB. + static class Bean4 { + + @JsonView(ViewB.class) + @JsonApplyView(ViewA.class) + public Bean2 wrapped = new Bean2(); + } + + /* + /********************************************************** + /* Unit tests + /********************************************************** + */ + + // Ensure `MapperFeature.DEFAULT_VIEW_INCLUSION` is enabled + // (its default differs b/w Jackson 2.x and 3.x) + private final ObjectMapper MAPPER = jsonMapperBuilder() + .enable(MapperFeature.DEFAULT_VIEW_INCLUSION) + .build(); + + // Mapper using the 3.x default (DEFAULT_VIEW_INCLUSION disabled), used to + // confirm @JsonApplyView semantics do not depend on this feature flag. + private final ObjectMapper MAPPER_NO_DEFAULT_INCLUSION = jsonMapperBuilder() + .disable(MapperFeature.DEFAULT_VIEW_INCLUSION) + .build(); + + @SuppressWarnings("unchecked") + @Test + public void testJsonApplyView() { + // With "ViewA" active: both Bean2 properties are @JsonView(ViewA), so both + // serialize, but each applies its own override view to the nested Bean. + Bean2 bean2 = new Bean2(); + + StringWriter sw = new StringWriter(); + MAPPER.writerWithView(ViewA.class).writeValue(sw, bean2); + + Map bean2Map = MAPPER.readValue(sw.toString(), Map.class); + assertEquals(2, bean2Map.size()); + + Map beanWithApplyViewBMap = (Map) bean2Map.get("beanWithApplyViewB"); + assertEquals(2, beanWithApplyViewBMap.size()); + assertFalse(beanWithApplyViewBMap.containsKey("a")); + assertEquals("2", beanWithApplyViewBMap.get("aa")); + assertEquals("3", beanWithApplyViewBMap.get("b")); + + Map beanWithApplyNoneViewMap = (Map) bean2Map.get("beanWithApplyNoneView"); + assertEquals(3, beanWithApplyNoneViewMap.size()); + assertEquals("1", beanWithApplyNoneViewMap.get("a")); + assertEquals("2", beanWithApplyNoneViewMap.get("aa")); + assertEquals("3", beanWithApplyNoneViewMap.get("b")); + } + + @SuppressWarnings("unchecked") + @Test + public void testJsonApplyViewNested() { + // With "ViewAA" active (extends ViewA): all three Bean3 properties are + // @JsonView(ViewA), so all serialize; verify nested @JsonApplyView still applies. + Bean3 bean3 = new Bean3(); + + StringWriter sw = new StringWriter(); + MAPPER.writerWithView(ViewAA.class).writeValue(sw, bean3); + + Map bean3Map = MAPPER.readValue(sw.toString(), Map.class); + assertEquals(3, bean3Map.size()); + + Map beanMap = (Map) bean3Map.get("bean"); + assertEquals(2, beanMap.size()); + assertEquals("1", beanMap.get("a")); + assertEquals("2", beanMap.get("aa")); + assertFalse(beanMap.containsKey("b")); + + Map bean2Map = (Map) bean3Map.get("bean2"); + assertEquals(2, bean2Map.size()); + Map nestedBeanWithApplyViewBMap = (Map) bean2Map.get("beanWithApplyViewB"); + assertEquals(2, nestedBeanWithApplyViewBMap.size()); + assertFalse(nestedBeanWithApplyViewBMap.containsKey("a")); + assertEquals("2", nestedBeanWithApplyViewBMap.get("aa")); + assertEquals("3", nestedBeanWithApplyViewBMap.get("b")); + + // NONE applyView reached via nested Bean2: all three Bean properties present + Map nestedBeanWithApplyNoneViewMap = (Map) bean2Map.get("beanWithApplyNoneView"); + assertEquals(3, nestedBeanWithApplyNoneViewMap.size()); + assertEquals("1", nestedBeanWithApplyNoneViewMap.get("a")); + assertEquals("2", nestedBeanWithApplyNoneViewMap.get("aa")); + assertEquals("3", nestedBeanWithApplyNoneViewMap.get("b")); + + Map bean2WithApplyViewBMap = (Map) bean3Map.get("bean2WithApplyViewB"); + assertEquals(0, bean2WithApplyViewBMap.size()); + } + + // Verifies an inner @JsonApplyView overrides an outer one: writer is ViewB, + // Bean4.wrapped switches the active view to ViewA, then Bean2's own + // @JsonApplyView re-fires for the deepest Bean. + @SuppressWarnings("unchecked") + @Test + public void testJsonApplyViewOverridesOuter() { + Bean4 bean4 = new Bean4(); + + StringWriter sw = new StringWriter(); + MAPPER.writerWithView(ViewB.class).writeValue(sw, bean4); + + Map bean4Map = MAPPER.readValue(sw.toString(), Map.class); + assertEquals(1, bean4Map.size()); + + Map wrappedMap = (Map) bean4Map.get("wrapped"); + // Both Bean2 fields are @JsonView(ViewA): they only survive because Bean4 + // applied ViewA, not the writer's ViewB. + assertEquals(2, wrappedMap.size()); + + // Inner @JsonApplyView(ViewB) wins over outer ViewA for the deepest Bean + Map deepWithApplyB = (Map) wrappedMap.get("beanWithApplyViewB"); + assertEquals(2, deepWithApplyB.size()); + assertFalse(deepWithApplyB.containsKey("a")); + assertEquals("2", deepWithApplyB.get("aa")); + assertEquals("3", deepWithApplyB.get("b")); + + // Inner @JsonApplyView(NONE) wins over outer ViewA: all three properties + Map deepWithApplyNone = (Map) wrappedMap.get("beanWithApplyNoneView"); + assertEquals(3, deepWithApplyNone.size()); + assertEquals("1", deepWithApplyNone.get("a")); + assertEquals("2", deepWithApplyNone.get("aa")); + assertEquals("3", deepWithApplyNone.get("b")); + } + + // Same scenario as testJsonApplyView but with DEFAULT_VIEW_INCLUSION disabled + // (3.x default). All test properties are explicitly @JsonView-annotated, so + // the feature must not affect outcomes; in particular NONE still yields all 3. + @SuppressWarnings("unchecked") + @Test + public void testJsonApplyViewWithoutDefaultInclusion() { + Bean2 bean2 = new Bean2(); + + StringWriter sw = new StringWriter(); + MAPPER_NO_DEFAULT_INCLUSION.writerWithView(ViewA.class).writeValue(sw, bean2); + + Map bean2Map = MAPPER_NO_DEFAULT_INCLUSION.readValue(sw.toString(), Map.class); + assertEquals(2, bean2Map.size()); + + Map beanWithApplyViewBMap = (Map) bean2Map.get("beanWithApplyViewB"); + assertEquals(2, beanWithApplyViewBMap.size()); + assertFalse(beanWithApplyViewBMap.containsKey("a")); + assertEquals("2", beanWithApplyViewBMap.get("aa")); + assertEquals("3", beanWithApplyViewBMap.get("b")); + + Map beanWithApplyNoneViewMap = (Map) bean2Map.get("beanWithApplyNoneView"); + assertEquals(3, beanWithApplyNoneViewMap.size()); + assertEquals("1", beanWithApplyNoneViewMap.get("a")); + assertEquals("2", beanWithApplyNoneViewMap.get("aa")); + assertEquals("3", beanWithApplyNoneViewMap.get("b")); + } +}