From 80c5b7ba3ec1fb5005c70ab54e5fe7947668e439 Mon Sep 17 00:00:00 2001 From: Whitney Sorenson Date: Fri, 30 Jan 2026 19:02:44 -0500 Subject: [PATCH] Add per-field serialization inclusion support for @StoredAsJson Allow @JsonInclude annotation on @StoredAsJson fields to control serialization inclusion for the nested JSON content only. This enables use cases like excluding empty collections from nested JSON while preserving the default ALWAYS behavior for top-level fields (required for proper DB column binding). The implementation creates a cached mapper copy with the standard JacksonAnnotationIntrospector (removing RosettaAnnotationIntrospector's ALWAYS override) when @JsonInclude is present on the field. Usage: @StoredAsJson @JsonInclude(Include.NON_EMPTY) InnerBean inner; // Empty collections excluded from nested JSON Co-Authored-By: Claude Opus 4.5 --- .../ContextualStoredAsJsonSerializer.java | 86 ++++++++++++++++++- .../rosetta/annotations/StoredAsJsonTest.java | 81 +++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/RosettaCore/src/main/java/com/hubspot/rosetta/internal/ContextualStoredAsJsonSerializer.java b/RosettaCore/src/main/java/com/hubspot/rosetta/internal/ContextualStoredAsJsonSerializer.java index 9252427..5144a07 100644 --- a/RosettaCore/src/main/java/com/hubspot/rosetta/internal/ContextualStoredAsJsonSerializer.java +++ b/RosettaCore/src/main/java/com/hubspot/rosetta/internal/ContextualStoredAsJsonSerializer.java @@ -1,5 +1,6 @@ package com.hubspot.rosetta.internal; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonEncoding; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.io.SegmentedStringWriter; @@ -10,19 +11,91 @@ import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.introspect.AnnotatedMember; +import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; import com.fasterxml.jackson.databind.ser.std.NonTypedScalarSerializerBase; import com.fasterxml.jackson.databind.ser.std.StdSerializer; import java.io.IOException; import java.io.Writer; +import java.lang.reflect.AnnotatedElement; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; abstract class ContextualStoredAsJsonSerializer extends NonTypedScalarSerializerBase { + private static final ConcurrentHashMap MAPPER_CACHE = + new ConcurrentHashMap<>(); + private final BeanProperty property; + private final JsonInclude.Include inclusion; ContextualStoredAsJsonSerializer(Class t, BeanProperty property) { super(t); this.property = property; + this.inclusion = findInclusion(property); + } + + private static JsonInclude.Include findInclusion(BeanProperty property) { + if (property == null) { + return null; + } + AnnotatedMember member = property.getMember(); + if (member != null) { + AnnotatedElement annotated = member.getAnnotated(); + if (annotated != null) { + JsonInclude annotation = annotated.getAnnotation(JsonInclude.class); + if ( + annotation != null && annotation.value() != JsonInclude.Include.USE_DEFAULTS + ) { + return annotation.value(); + } + } + } + return null; + } + + private ObjectMapper getConfiguredMapper(ObjectMapper baseMapper) { + if (inclusion == null) { + return baseMapper; + } + return MAPPER_CACHE.computeIfAbsent( + new InclusionCacheKey(baseMapper, inclusion), + key -> { + ObjectMapper nestedMapper = baseMapper.copy(); + nestedMapper.setAnnotationIntrospector(new JacksonAnnotationIntrospector()); + nestedMapper.setSerializationInclusion(key.inclusion); + return nestedMapper; + } + ); + } + + private static class InclusionCacheKey { + + final int mapperIdentity; + final JsonInclude.Include inclusion; + + InclusionCacheKey(ObjectMapper mapper, JsonInclude.Include inclusion) { + this.mapperIdentity = System.identityHashCode(mapper); + this.inclusion = inclusion; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof InclusionCacheKey)) { + return false; + } + InclusionCacheKey that = (InclusionCacheKey) o; + return mapperIdentity == that.mapperIdentity && inclusion == that.inclusion; + } + + @Override + public int hashCode() { + return Objects.hash(mapperIdentity, inclusion); + } } protected void serializeAsBytes( @@ -56,6 +129,10 @@ private byte[] serializeToBytes( ObjectMapper mapper, SerializerProvider provider ) throws IOException { + if (inclusion != null) { + return mapper.writeValueAsBytes(value); + } + try (ByteArrayBuilder array = new ByteArrayBuilder(new BufferRecycler())) { if (trySerialzieToArray(value, mapper, provider, array)) { byte[] result = array.toByteArray(); @@ -64,7 +141,6 @@ private byte[] serializeToBytes( } } - // fallback on old behavior return mapper.writeValueAsBytes(value); } @@ -73,13 +149,16 @@ private String serializeToString( ObjectMapper mapper, SerializerProvider provider ) throws IOException { + if (inclusion != null) { + return mapper.writeValueAsString(value); + } + try (SegmentedStringWriter sw = new SegmentedStringWriter(new BufferRecycler())) { if (trySerializeToWriter(value, mapper, provider, sw)) { return sw.getAndClear(); } } - // fallback on old behavior JsonNode tree = mapper.valueToTree(value); if (tree.isNull()) { return tree.asText(); @@ -136,6 +215,7 @@ private boolean trySerializeToGenerator( } private ObjectMapper getMapper(JsonGenerator generator) { - return (ObjectMapper) generator.getCodec(); + ObjectMapper baseMapper = (ObjectMapper) generator.getCodec(); + return getConfiguredMapper(baseMapper); } } diff --git a/RosettaCore/src/test/java/com/hubspot/rosetta/annotations/StoredAsJsonTest.java b/RosettaCore/src/test/java/com/hubspot/rosetta/annotations/StoredAsJsonTest.java index 3c59c8f..9ea56e6 100644 --- a/RosettaCore/src/test/java/com/hubspot/rosetta/annotations/StoredAsJsonTest.java +++ b/RosettaCore/src/test/java/com/hubspot/rosetta/annotations/StoredAsJsonTest.java @@ -2,8 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.BinaryNode; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -31,9 +33,11 @@ import com.hubspot.rosetta.beans.StoredAsJsonListTypeInfoBean.ConcreteStoredAsJsonList; import com.hubspot.rosetta.beans.StoredAsJsonTypeInfoBean; import com.hubspot.rosetta.beans.StoredAsJsonTypeInfoBean.ConcreteStoredAsJsonTypeInfo; +import com.hubspot.rosetta.internal.RosettaModule; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; @@ -785,4 +789,81 @@ public void testDeserializingStoredAsJsonPrivateField() throws Exception { .getMapper() .readValue(node.toString(), FieldBeanStoredAsJson.class); } + + @Test + public void itIncludesAllFieldsByDefaultWithRosettaMapper() + throws JsonProcessingException { + // Verify that the default Rosetta.getMapper() still includes all fields + // including nulls and empty collections (standard DAO behavior) + InnerBeanWithList bean = new InnerBeanWithList(); + bean.values = Collections.emptyList(); + bean.name = null; + + String json = Rosetta.getMapper().writeValueAsString(bean); + + // Both fields should be present even though one is null and one is empty + assertThat(json).contains("\"name\":null"); + assertThat(json).contains("\"values\":[]"); + } + + @Test + public void itIgnoresMapperLevelInclusionForStoredAsJsonFields() + throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new RosettaModule()) + .setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + + BeanWithListStoredAsJsonNoAnnotation bean = + new BeanWithListStoredAsJsonNoAnnotation(); + bean.inner = new InnerBeanWithList(); + bean.inner.values = Collections.emptyList(); + bean.inner.name = "test"; + + JsonNode node = mapper.valueToTree(bean); + + assertThat(node.get("inner").isTextual()).isTrue(); + String innerJson = node.get("inner").textValue(); + assertThat(innerJson) + .as("Mapper-level NON_EMPTY should NOT affect @StoredAsJson without @JsonInclude") + .contains("\"values\":[]"); + } + + @Test + public void itRespectsJsonIncludeAnnotationOnStoredAsJsonField() + throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper().registerModule(new RosettaModule()); + + BeanWithListStoredAsJson bean = new BeanWithListStoredAsJson(); + bean.inner = new InnerBeanWithList(); + bean.inner.values = Collections.emptyList(); + bean.inner.name = "test"; + + JsonNode node = mapper.valueToTree(bean); + + assertThat(node.get("inner").isTextual()).isTrue(); + String innerJson = node.get("inner").textValue(); + assertThat(innerJson).contains("\"name\":\"test\""); + assertThat(innerJson) + .as("@JsonInclude(NON_EMPTY) should exclude empty list in @StoredAsJson field") + .doesNotContain("values"); + } + + public static class BeanWithListStoredAsJson { + + @StoredAsJson + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public InnerBeanWithList inner; + } + + public static class BeanWithListStoredAsJsonNoAnnotation { + + @StoredAsJson + public InnerBeanWithList inner; + } + + public static class InnerBeanWithList { + + public String name; + public List values; + } }