Skip to content

Commit 40393a0

Browse files
feat: lift @JsonWrapped scalar-only restriction — support POJOs, collections, maps, arrays
Removes the scalar-only validation guards from BeanSerializerFactory and BeanDeserializerBase that blocked non-scalar types. The wrapper contract is unchanged — inner fields delegate to their own serializers/deserializers, so any Jackson-serializable type works without new machinery. Updates Javadoc, README, and converts former rejection tests to positive tests; adds serialization, deserialization, and round-trip coverage for POJO, List, Map, Array, mixed-type, and nested-wrapping scenarios. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d3535d1 commit 40393a0

8 files changed

Lines changed: 386 additions & 97 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ are set via setters or directly using fields.
433433

434434
### Annotations: wrapping properties
435435

436-
`@JsonWrapped` groups one or more scalar fields into a synthetic nested JSON object — the inverse of `@JsonUnwrapped`.
436+
`@JsonWrapped` groups one or more fields into a synthetic nested JSON object — the inverse of `@JsonUnwrapped`. Any Jackson-serializable type (scalars, POJOs, collections, maps, arrays) can be used as an inner field.
437437

438438
```java
439439
public class Gene {
@@ -458,7 +458,11 @@ Serializing a `Gene` instance produces:
458458

459459
Deserialization is also supported: Jackson reads the nested `"chr"` object and maps its fields back to the annotated POJO fields (round-trip).
460460

461-
Note: only scalar/primitive fields may be wrapped — annotating a collection, map, array, or nested POJO will cause a mapping error.
461+
Note: non-scalar types (POJOs, collections, maps, arrays) are supported for baseline
462+
serialization/deserialization. Existing interaction limitations around `@JsonView`,
463+
`@JsonFilter`, and `@JsonInclude` on inner wrapped fields still apply — see the
464+
[`@JsonWrapped` Javadoc](src/main/java/tools/jackson/databind/annotation/JsonWrapped.java)
465+
for the full list of constraints.
462466

463467
See the [`@JsonWrapped` Javadoc](src/main/java/tools/jackson/databind/annotation/JsonWrapped.java) for the full list of constraints.
464468

src/main/java/tools/jackson/databind/annotation/JsonWrapped.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import com.fasterxml.jackson.annotation.JacksonAnnotation;
99

1010
/**
11-
* Annotation that groups one or more scalar bean properties into a synthetic
11+
* Annotation that groups one or more bean properties into a synthetic
1212
* nested JSON object during serialization, and extracts them back during
1313
* deserialization. This is the inverse of {@link com.fasterxml.jackson.annotation.JsonUnwrapped}.
1414
*
@@ -41,8 +41,11 @@
4141
*
4242
* <p>Constraints:
4343
* <ul>
44-
* <li>MVP limitation: only scalar and primitive types are currently supported as wrapped
45-
* fields (containers, maps, arrays, and nested POJOs are not yet supported).</li>
44+
* <li>Non-scalar field types (POJOs, collections, maps, arrays) are supported for
45+
* baseline serialization and deserialization. Each inner field serializes under
46+
* its own name within the wrapper object. Note: existing interaction limitations
47+
* around {@code @JsonView}, {@code @JsonFilter}, and {@code @JsonInclude} on
48+
* inner wrapped fields still apply — see the remaining bullets below.</li>
4649
* <li>The wrapper name ({@code value()}) must be non-empty, unless explicitly disabling
4750
* wrapping: an empty {@code value()} ({@code @JsonWrapped("")}) disables wrapping —
4851
* useful in mix-ins to suppress wrapping defined in a supertype.</li>

src/main/java/tools/jackson/databind/deser/bean/BeanDeserializerBase.java

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -685,15 +685,6 @@ public void resolve(DeserializationContext ctxt)
685685
prop.getName()));
686686
}
687687

688-
// Validate: scalar-only (symmetry with serialization)
689-
JavaType type = prop.getType();
690-
if (!_isScalarType(type)) {
691-
ctxt.reportBadDefinition(handledType(),
692-
String.format("@JsonWrapped is only supported on scalar/primitive types; "
693-
+ "found %s on property '%s'",
694-
type.toCanonical(), prop.getName()));
695-
}
696-
697688
groups.computeIfAbsent(wrapperName, k -> new ArrayList<>()).add(prop);
698689
}
699690

@@ -2066,7 +2057,4 @@ protected Object wrapInstantiationProblem(DeserializationContext ctxt,Throwable
20662057
return ctxt.handleInstantiationProblem(_beanType.getRawClass(), null, t);
20672058
}
20682059

2069-
protected boolean _isScalarType(JavaType type) {
2070-
return BeanUtil.isScalarType(type);
2071-
}
20722060
}

src/main/java/tools/jackson/databind/ser/BeanSerializerFactory.java

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -916,15 +916,6 @@ protected List<BeanPropertyWriter> _groupWrappedProperties(SerializationContext
916916
// Cache the wrapper name for later use
917917
wrapperNameCache.put(prop, wrapperName);
918918

919-
// Validate: scalar-only
920-
JavaType type = prop.getType();
921-
if (!_isScalarType(type)) {
922-
ctxt.reportBadTypeDefinition(beanDescRef,
923-
"@JsonWrapped is only supported on scalar/primitive types; found %s on property '%s'",
924-
type.toCanonical(), prop.getName());
925-
continue;
926-
}
927-
928919
if (groups == null) {
929920
groups = new LinkedHashMap<>();
930921
}
@@ -1026,7 +1017,4 @@ protected List<BeanPropertyWriter> _groupWrappedProperties(SerializationContext
10261017
return result;
10271018
}
10281019

1029-
protected boolean _isScalarType(JavaType type) {
1030-
return BeanUtil.isScalarType(type);
1031-
}
10321020
}

src/main/java/tools/jackson/databind/util/BeanUtil.java

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
package tools.jackson.databind.util;
22

3-
import java.net.URI;
4-
import java.net.URL;
5-
import java.time.temporal.TemporalAccessor;
6-
import java.time.temporal.TemporalAmount;
73
import java.util.Calendar;
84
import java.util.Date;
95
import java.util.GregorianCalendar;
10-
import java.util.UUID;
116

127
import com.fasterxml.jackson.annotation.JsonInclude;
138

@@ -195,37 +190,4 @@ public static boolean isJodaTimeClass(Class<?> rawType) {
195190
private static boolean isJodaTimeClass(String className) {
196191
return className.startsWith("org.joda.time.");
197192
}
198-
199-
/*
200-
/**********************************************************************
201-
/* Type classification helpers
202-
/**********************************************************************
203-
*/
204-
205-
/**
206-
* Returns {@code true} if the given type serializes as a single JSON scalar
207-
* value (not as an object or array). Includes: Java primitives and their
208-
* wrappers, {@code String}, {@code Number} subtypes, {@code Enum} types, and
209-
* common single-value types ({@code java.time.*}, {@code java.util.UUID},
210-
* {@code java.net.URI}, {@code java.net.URL}).
211-
* Container types, Map types, arrays, and custom POJOs return {@code false}.
212-
*
213-
* @since 3.1
214-
*/
215-
public static boolean isScalarType(JavaType type) {
216-
Class<?> raw = type.getRawClass();
217-
if (raw.isPrimitive()) return true;
218-
if (raw == Boolean.class || raw == Byte.class || raw == Short.class
219-
|| raw == Integer.class || raw == Long.class
220-
|| raw == Float.class || raw == Double.class
221-
|| raw == Character.class) return true;
222-
if (raw == String.class) return true;
223-
if (Number.class.isAssignableFrom(raw)) return true;
224-
if (type.isEnumType()) return true;
225-
if (raw == UUID.class) return true;
226-
if (raw == URI.class) return true;
227-
if (raw == URL.class) return true;
228-
return TemporalAccessor.class.isAssignableFrom(raw)
229-
|| TemporalAmount.class.isAssignableFrom(raw);
230-
}
231193
}

src/test/java/tools/jackson/databind/struct/JsonWrappedDeserializationTest.java

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,62 @@ static class MultiWrapperBean {
7272
public MultiWrapperBean() { }
7373
}
7474

75+
static class City {
76+
public String name;
77+
public int population;
78+
public City() { }
79+
}
80+
81+
static class BeanWithPojoWrapped {
82+
@JsonWrapped("w")
83+
public City city;
84+
public BeanWithPojoWrapped() { }
85+
}
86+
87+
static class BeanWithListWrapped {
88+
@JsonWrapped("w")
89+
public java.util.List<String> tags;
90+
public BeanWithListWrapped() { }
91+
}
92+
93+
static class BeanWithMapWrapped {
94+
@JsonWrapped("w")
95+
public java.util.Map<String, Integer> counts;
96+
public BeanWithMapWrapped() { }
97+
}
98+
99+
static class BeanWithArrayWrapped {
100+
@JsonWrapped("w")
101+
public String[] items;
102+
public BeanWithArrayWrapped() { }
103+
}
104+
105+
static class BeanWithMixedWrapper {
106+
@JsonWrapped("w")
107+
public String label;
108+
109+
@JsonWrapped("w")
110+
public City city;
111+
112+
public BeanWithMixedWrapper() { }
113+
}
114+
115+
static class InnerWithWrapped {
116+
@JsonWrapped("sub")
117+
public String x;
118+
119+
@JsonWrapped("sub")
120+
public String y;
121+
122+
public InnerWithWrapped() { }
123+
}
124+
125+
static class BeanWithNestedWrapping {
126+
@JsonWrapped("outer")
127+
public InnerWithWrapped inner;
128+
public BeanWithNestedWrapping() { }
129+
}
130+
75131
private final ObjectMapper MAPPER = newJsonMapper();
76132

77133
@Nested
@@ -304,4 +360,109 @@ public boolean handleUnknownProperty(DeserializationContext ctxt,
304360
assertThat(gene.chrName).isEqualTo("chr17");
305361
}
306362
}
363+
364+
@Nested
365+
@DisplayName("non-scalar deserialization tests")
366+
class NonScalarDeserializationTests {
367+
368+
@Test
369+
@DisplayName("should deserialize POJO field from wrapper object")
370+
void pojoInsideWrapper() throws Exception {
371+
String json = "{\"w\":{\"city\":{\"name\":\"NYC\",\"population\":8000000}}}";
372+
373+
BeanWithPojoWrapped bean = MAPPER.readValue(json, BeanWithPojoWrapped.class);
374+
375+
assertThat(bean.city).isNotNull();
376+
assertThat(bean.city.name).isEqualTo("NYC");
377+
assertThat(bean.city.population).isEqualTo(8_000_000);
378+
}
379+
380+
@Test
381+
@DisplayName("should deserialize null POJO field from wrapper object")
382+
void nullPojoInsideWrapper() throws Exception {
383+
String json = "{\"w\":{\"city\":null}}";
384+
385+
BeanWithPojoWrapped bean = MAPPER.readValue(json, BeanWithPojoWrapped.class);
386+
387+
assertThat(bean.city).isNull();
388+
}
389+
390+
@Test
391+
@DisplayName("should deserialize List field from wrapper object")
392+
void listInsideWrapper() throws Exception {
393+
String json = "{\"w\":{\"tags\":[\"java\",\"jackson\"]}}";
394+
395+
BeanWithListWrapped bean = MAPPER.readValue(json, BeanWithListWrapped.class);
396+
397+
assertThat(bean.tags).containsExactly("java", "jackson");
398+
}
399+
400+
@Test
401+
@DisplayName("should deserialize Map field from wrapper object")
402+
void mapInsideWrapper() throws Exception {
403+
String json = "{\"w\":{\"counts\":{\"a\":1,\"b\":2}}}";
404+
405+
BeanWithMapWrapped bean = MAPPER.readValue(json, BeanWithMapWrapped.class);
406+
407+
assertThat(bean.counts).containsEntry("a", 1).containsEntry("b", 2);
408+
}
409+
410+
@Test
411+
@DisplayName("should deserialize array field from wrapper object")
412+
void arrayInsideWrapper() throws Exception {
413+
String json = "{\"w\":{\"items\":[\"x\",\"y\"]}}";
414+
415+
BeanWithArrayWrapped bean = MAPPER.readValue(json, BeanWithArrayWrapped.class);
416+
417+
assertThat(bean.items).containsExactly("x", "y");
418+
}
419+
420+
@Test
421+
@DisplayName("should deserialize mixed scalar and POJO from same wrapper")
422+
void mixedScalarAndPojoSameWrapper() throws Exception {
423+
String json = "{\"w\":{\"label\":\"home\",\"city\":{\"name\":\"NYC\",\"population\":8000000}}}";
424+
425+
BeanWithMixedWrapper bean = MAPPER.readValue(json, BeanWithMixedWrapper.class);
426+
427+
assertThat(bean.label).isEqualTo("home");
428+
assertThat(bean.city).isNotNull();
429+
assertThat(bean.city.name).isEqualTo("NYC");
430+
}
431+
432+
@Test
433+
@DisplayName("should round-trip POJO inside wrapper")
434+
void roundTripPojoInsideWrapper() throws Exception {
435+
String originalJson = "{\"w\":{\"city\":{\"name\":\"NYC\",\"population\":8000000}}}";
436+
BeanWithPojoWrapped bean = MAPPER.readValue(originalJson, BeanWithPojoWrapped.class);
437+
438+
String roundTripped = MAPPER.writeValueAsString(bean);
439+
440+
assertThat(roundTripped).isEqualTo(originalJson);
441+
}
442+
443+
@Test
444+
@DisplayName("should round-trip List inside wrapper")
445+
void roundTripListInsideWrapper() throws Exception {
446+
String originalJson = "{\"w\":{\"tags\":[\"java\",\"jackson\"]}}";
447+
BeanWithListWrapped bean = MAPPER.readValue(originalJson, BeanWithListWrapped.class);
448+
449+
String roundTripped = MAPPER.writeValueAsString(bean);
450+
451+
assertThat(roundTripped).isEqualTo(originalJson);
452+
}
453+
454+
@Test
455+
@DisplayName("should compose nested @JsonWrapped — POJO inside wrapper that itself has @JsonWrapped fields")
456+
void nestedWrappingRoundTrip() throws Exception {
457+
String originalJson = "{\"outer\":{\"inner\":{\"sub\":{\"x\":\"a\",\"y\":\"b\"}}}}";
458+
BeanWithNestedWrapping bean = MAPPER.readValue(originalJson, BeanWithNestedWrapping.class);
459+
460+
assertThat(bean.inner).isNotNull();
461+
assertThat(bean.inner.x).isEqualTo("a");
462+
assertThat(bean.inner.y).isEqualTo("b");
463+
464+
String roundTripped = MAPPER.writeValueAsString(bean);
465+
assertThat(roundTripped).isEqualTo(originalJson);
466+
}
467+
}
307468
}

0 commit comments

Comments
 (0)