Skip to content

Commit bc7b5f9

Browse files
ISSUE-512: Add @JsonWrapped annotation for grouping scalar fields into nested JSON objects
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b9dc6ce commit bc7b5f9

15 files changed

Lines changed: 1805 additions & 1 deletion

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,37 @@ Note that use of a "creator method" (`@JsonCreator` with `@JsonProperty` annotat
431431
can mix and match properties from constructor/factory method with ones that
432432
are set via setters or directly using fields.
433433

434+
### Annotations: wrapping properties
435+
436+
`@JsonWrapped` groups one or more scalar fields into a synthetic nested JSON object — the inverse of `@JsonUnwrapped`.
437+
438+
```java
439+
public class Gene {
440+
public String name;
441+
442+
@JsonWrapped("chr") public String chromosome;
443+
@JsonWrapped("chr") public int position;
444+
}
445+
```
446+
447+
Serializing a `Gene` instance produces:
448+
449+
```json
450+
{
451+
"name" : "BRCA1",
452+
"chr" : {
453+
"chromosome" : "17",
454+
"position" : 43044295
455+
}
456+
}
457+
```
458+
459+
Deserialization is also supported: Jackson reads the nested `"chr"` object and maps its fields back to the annotated POJO fields (round-trip).
460+
461+
Note: only scalar/primitive fields may be wrapped — annotating a collection, map, array, or nested POJO will cause a mapping error.
462+
463+
See the [`@JsonWrapped` Javadoc](src/main/java/tools/jackson/databind/annotation/JsonWrapped.java) for the full list of constraints.
464+
434465
## Tutorial: fancier stuff, conversions
435466

436467
One useful (but not very widely known) feature of Jackson is its ability

src/main/java/tools/jackson/databind/AnnotationIntrospector.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,17 @@ public Object findTypeIdResolver(MapperConfig<?> config, Annotated ann) {
511511
*/
512512
public NameTransformer findUnwrappingNameTransformer(MapperConfig<?> config, AnnotatedMember member) { return null; }
513513

514+
/**
515+
* Method for finding the wrapped group name for a member annotated
516+
* with {@code @JsonWrapped}. Returns the wrapper object name if the
517+
* member should be wrapped, or {@code null} if not.
518+
*
519+
* @since 3.1
520+
*/
521+
public String findWrappedGroupName(MapperConfig<?> config, AnnotatedMember member) {
522+
return null;
523+
}
524+
514525
/**
515526
* Method called to check whether given property is marked to
516527
* be ignored. This is used to determine whether to ignore
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package tools.jackson.databind.annotation;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
import com.fasterxml.jackson.annotation.JacksonAnnotation;
9+
10+
/**
11+
* Annotation that groups one or more scalar bean properties into a synthetic
12+
* nested JSON object during serialization, and extracts them back during
13+
* deserialization. This is the inverse of {@link com.fasterxml.jackson.annotation.JsonUnwrapped}.
14+
*
15+
* <p>Multiple fields annotated with the same {@code value()} are grouped into
16+
* a single wrapper object. Inner property names follow Jackson's standard naming
17+
* ({@code @JsonProperty} or default).
18+
*
19+
* <p>Example: given a POJO such as:
20+
* <pre>
21+
* public class Gene {
22+
* public String name;
23+
*
24+
* &#64;JsonWrapped("chr")
25+
* public String chromosome;
26+
*
27+
* &#64;JsonWrapped("chr")
28+
* public int position;
29+
* }
30+
* </pre>
31+
* serialization produces:
32+
* <pre>
33+
* {
34+
* "name" : "BRCA1",
35+
* "chr" : {
36+
* "chromosome" : "17",
37+
* "position" : 43044295
38+
* }
39+
* }
40+
* </pre>
41+
*
42+
* <p>Constraints:
43+
* <ul>
44+
* <li>Only scalar and primitive types are supported as wrapped fields
45+
* (containers, maps, arrays, and nested POJOs will cause a mapping error).</li>
46+
* <li>The wrapper name ({@code value()}) must be non-empty.</li>
47+
* <li>The wrapper name must not conflict with an existing non-wrapped property on the same bean.</li>
48+
* <li>Not supported on {@code @JsonCreator} constructor or factory-method parameters.</li>
49+
* <li>MVP limitation: {@code @JsonView} on inner wrapped fields is ignored — the wrapper
50+
* is always emitted and all inner fields are always included regardless of active view.</li>
51+
* <li>MVP limitation: class-level {@code @JsonFilter} still applies to the wrapper property
52+
* by its wrapper name (the whole wrapper can be suppressed if the filter excludes it),
53+
* but inner fields are not individually filtered.</li>
54+
* <li>MVP limitation: class-level {@code @JsonInclude} (e.g. {@code NON_NULL}) still applies
55+
* to inner wrapped fields during serialization.</li>
56+
* </ul>
57+
*
58+
* @see com.fasterxml.jackson.annotation.JsonUnwrapped
59+
* @since 3.1
60+
*/
61+
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
62+
@Retention(RetentionPolicy.RUNTIME)
63+
@JacksonAnnotation
64+
public @interface JsonWrapped {
65+
/**
66+
* Single-level wrapper object name (e.g. "chr").
67+
* Must be non-empty.
68+
*/
69+
String value();
70+
71+
/**
72+
* Allows conditional disabling of wrapping.
73+
*/
74+
boolean enabled() default true;
75+
}

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,8 +590,14 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) t
590590
}
591591
if (_nonStandardCreation) {
592592
if (_unwrappedPropertyHandler != null) {
593+
if (_wrappedPropertyHandler != null) {
594+
return deserializeWithUnwrappedAndWrapped(p, ctxt); // both present
595+
}
593596
return deserializeWithUnwrapped(p, ctxt);
594597
}
598+
if (_wrappedPropertyHandler != null) {
599+
return deserializeWithWrapped(p, ctxt); // only wrapped
600+
}
595601
if (_externalTypeIdHandler != null) {
596602
return deserializeWithExternalTypeId(p, ctxt);
597603
}
@@ -1357,6 +1363,107 @@ protected Object _deserializeWithExternalTypeId(JsonParser p, DeserializationCon
13571363
return ext.complete(p, ctxt, bean);
13581364
}
13591365

1366+
/**
1367+
* Deserialization method for beans that have {@code @JsonWrapped} properties.
1368+
* Similar to standard property-by-property deserialization, but recognizes
1369+
* wrapper field names and dispatches to WrappedPropertyHandler.
1370+
*/
1371+
protected Object deserializeWithWrapped(JsonParser p, DeserializationContext ctxt)
1372+
throws JacksonException
1373+
{
1374+
if (_delegateDeserializer != null) {
1375+
return _valueInstantiator.createUsingDelegate(ctxt,
1376+
_delegateDeserializer.deserialize(p, ctxt));
1377+
}
1378+
if (_propertyBasedCreator != null) {
1379+
// For MVP, wrapped properties on creator params are rejected during resolve()
1380+
// so this shouldn't be reached with wrapped creator properties.
1381+
// Fall through to non-default creation.
1382+
return deserializeFromObjectUsingNonDefault(p, ctxt);
1383+
}
1384+
final Object bean = _valueInstantiator.createUsingDefault(ctxt);
1385+
p.assignCurrentValue(bean);
1386+
1387+
if (_injectables != null) {
1388+
injectValues(ctxt, bean);
1389+
}
1390+
final Class<?> activeView = _needViewProcesing ? ctxt.getActiveView() : null;
1391+
1392+
for (int ix = p.currentNameMatch(_propNameMatcher); ;
1393+
ix = p.nextNameMatch(_propNameMatcher)) {
1394+
if (ix >= 0) {
1395+
// Known regular property
1396+
p.nextToken();
1397+
SettableBeanProperty prop = _propsByIndex[ix];
1398+
if (activeView != null && !prop.visibleInView(activeView)) {
1399+
p.skipChildren();
1400+
continue;
1401+
}
1402+
try {
1403+
prop.deserializeAndSet(p, ctxt, bean);
1404+
} catch (Exception e) {
1405+
throw wrapAndThrow(e, bean, prop.getName(), ctxt);
1406+
}
1407+
continue;
1408+
}
1409+
if (ix == PropertyNameMatcher.MATCH_END_OBJECT) {
1410+
break;
1411+
}
1412+
if (ix == PropertyNameMatcher.MATCH_ODD_TOKEN) {
1413+
// error handling for odd tokens
1414+
return _handleUnexpectedWithin(p, ctxt, bean);
1415+
}
1416+
// MATCH_UNKNOWN_NAME — check if it's a wrapper name
1417+
String propName = p.currentName();
1418+
p.nextToken();
1419+
1420+
if (_wrappedPropertyHandler.hasWrapperName(propName)) {
1421+
_wrappedPropertyHandler.handleWrappedObject(p, ctxt, bean,
1422+
propName, _anySetter);
1423+
continue;
1424+
}
1425+
1426+
// Standard unknown property handling
1427+
if (_ignorableProps != null && _ignorableProps.contains(propName)) {
1428+
handleIgnoredProperty(p, ctxt, bean, propName);
1429+
continue;
1430+
}
1431+
if (_anySetter != null) {
1432+
try {
1433+
_anySetter.deserializeAndSet(p, ctxt, bean, propName);
1434+
} catch (Exception e) {
1435+
throw wrapAndThrow(e, bean, propName, ctxt);
1436+
}
1437+
continue;
1438+
}
1439+
handleUnknownProperty(p, ctxt, bean, propName);
1440+
}
1441+
return bean;
1442+
}
1443+
1444+
/**
1445+
* Deserialization method for beans that have <em>both</em> {@code @JsonUnwrapped} and
1446+
* {@code @JsonWrapped} properties.
1447+
*
1448+
* <p><strong>MVP limitation:</strong> This method currently delegates entirely to
1449+
* {@link #deserializeWithWrapped}, which means {@code @JsonUnwrapped} properties are
1450+
* <em>silently ignored</em> — they are removed from {@code _beanProperties} during
1451+
* {@code resolve()} and {@code _unwrappedPropertyHandler} is never invoked here.
1452+
* A full implementation must interleave the unwrapped-property token-buffering logic
1453+
* from {@link #deserializeWithUnwrapped} with the inline wrapper dispatch logic from
1454+
* {@link #deserializeWithWrapped}.
1455+
*
1456+
* <p>TODO: Implement proper combined handling (post-MVP).
1457+
*
1458+
* @see #deserializeWithWrapped
1459+
* @see #deserializeWithUnwrapped
1460+
*/
1461+
protected Object deserializeWithUnwrappedAndWrapped(JsonParser p, DeserializationContext ctxt)
1462+
throws JacksonException
1463+
{
1464+
return deserializeWithWrapped(p, ctxt);
1465+
}
1466+
13601467
@SuppressWarnings("resource")
13611468
protected Object deserializeUsingPropertyBasedWithExternalTypeId(JsonParser p, DeserializationContext ctxt)
13621469
throws JacksonException

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ public abstract class BeanDeserializerBase
182182
*/
183183
protected UnwrappedPropertyHandler _unwrappedPropertyHandler;
184184

185+
/**
186+
* Handler for properties annotated with {@code @JsonWrapped}, if any.
187+
*
188+
* @since 3.1
189+
*/
190+
protected WrappedPropertyHandler _wrappedPropertyHandler;
191+
185192
/**
186193
* Handler that we need if any of properties uses external
187194
* type id.
@@ -284,6 +291,7 @@ protected BeanDeserializerBase(BeanDeserializerBase src, boolean ignoreAllUnknow
284291

285292
_nonStandardCreation = src._nonStandardCreation;
286293
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
294+
_wrappedPropertyHandler = src._wrappedPropertyHandler;
287295
_needViewProcesing = src._needViewProcesing;
288296
_serializationShape = src._serializationShape;
289297

@@ -350,6 +358,7 @@ protected BeanDeserializerBase(BeanDeserializerBase src, ObjectIdReader oir)
350358

351359
_nonStandardCreation = src._nonStandardCreation;
352360
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
361+
_wrappedPropertyHandler = src._wrappedPropertyHandler;
353362
_needViewProcesing = src._needViewProcesing;
354363
_serializationShape = src._serializationShape;
355364

@@ -394,6 +403,7 @@ public BeanDeserializerBase(BeanDeserializerBase src,
394403

395404
_nonStandardCreation = src._nonStandardCreation;
396405
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
406+
_wrappedPropertyHandler = src._wrappedPropertyHandler;
397407
_needViewProcesing = src._needViewProcesing;
398408
_serializationShape = src._serializationShape;
399409

@@ -428,6 +438,7 @@ protected BeanDeserializerBase(BeanDeserializerBase src, BeanPropertyMap beanPro
428438

429439
_nonStandardCreation = src._nonStandardCreation;
430440
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
441+
_wrappedPropertyHandler = src._wrappedPropertyHandler;
431442
_needViewProcesing = src._needViewProcesing;
432443
_serializationShape = src._serializationShape;
433444

@@ -642,6 +653,87 @@ public void resolve(DeserializationContext ctxt)
642653
} else {
643654
_unwrappedPropertyHandler = null;
644655
}
656+
657+
// Detect @JsonWrapped properties
658+
WrappedPropertyHandler wrapped = null;
659+
AnnotationIntrospector intr = ctxt.getAnnotationIntrospector();
660+
if (intr != null) {
661+
// Iterate a copy since we'll modify _beanProperties
662+
List<SettableBeanProperty> allProps = new ArrayList<>();
663+
for (SettableBeanProperty prop : _beanProperties) {
664+
allProps.add(prop);
665+
}
666+
667+
// First pass: collect wrapped properties and validate
668+
Map<String, List<SettableBeanProperty>> groups = new LinkedHashMap<>();
669+
Set<SettableBeanProperty> wrappedPropSet = new HashSet<>();
670+
for (SettableBeanProperty prop : allProps) {
671+
AnnotatedMember member = prop.getMember();
672+
if (member == null) continue;
673+
String wrapperName = intr.findWrappedGroupName(ctxt.getConfig(), member);
674+
if (wrapperName == null) continue;
675+
wrappedPropSet.add(prop);
676+
677+
// Validate: empty name
678+
if (wrapperName.isEmpty()) {
679+
ctxt.reportBadDefinition(handledType(),
680+
String.format("@JsonWrapped value must not be empty (on property '%s')",
681+
prop.getName()));
682+
}
683+
684+
// Validate: creator parameter
685+
if ((prop instanceof CreatorProperty) && ((CreatorProperty) prop).getCreatorIndex() >= 0) {
686+
ctxt.reportBadDefinition(handledType(),
687+
String.format("@JsonWrapped on creator parameter '%s' is not supported",
688+
prop.getName()));
689+
}
690+
691+
// Validate: scalar-only (symmetry with serialization)
692+
JavaType type = prop.getType();
693+
if (!_isScalarType(type)) {
694+
ctxt.reportBadDefinition(handledType(),
695+
String.format("@JsonWrapped is only supported on scalar/primitive types; "
696+
+ "found %s on property '%s'",
697+
type.toCanonical(), prop.getName()));
698+
}
699+
700+
groups.computeIfAbsent(wrapperName, k -> new ArrayList<>()).add(prop);
701+
}
702+
703+
if (!groups.isEmpty()) {
704+
// Validate: name conflicts (wrapper name vs non-wrapped property name)
705+
Set<String> wrapperNames = groups.keySet();
706+
for (SettableBeanProperty prop : allProps) {
707+
if (wrappedPropSet.contains(prop)) continue; // this is a wrapped property, skip
708+
if (wrapperNames.contains(prop.getName())) {
709+
ctxt.reportBadDefinition(handledType(),
710+
String.format("Wrapper name '%s' conflicts with existing property '%s'",
711+
prop.getName(), prop.getName()));
712+
}
713+
}
714+
715+
// Build handler and remove wrapped properties from bean properties
716+
wrapped = new WrappedPropertyHandler();
717+
for (Map.Entry<String, List<SettableBeanProperty>> entry : groups.entrySet()) {
718+
for (SettableBeanProperty prop : entry.getValue()) {
719+
wrapped.addProperty(entry.getKey(), prop);
720+
_beanProperties.remove(prop);
721+
}
722+
}
723+
}
724+
}
725+
726+
if (wrapped != null) {
727+
if (_unwrappedPropertyHandler != null) {
728+
ctxt.reportBadDefinition(handledType(),
729+
String.format("Cannot use both @JsonWrapped and @JsonUnwrapped on the same bean '%s': "
730+
+ "combined deserialization is not supported (post-MVP limitation). "
731+
+ "Use one or the other.", ClassUtil.classNameOf(handledType())));
732+
}
733+
_wrappedPropertyHandler = wrapped;
734+
_nonStandardCreation = true;
735+
}
736+
645737
// may need to disable vanilla processing, if unwrapped handling was enabled...
646738
_vanillaProcessing = _vanillaProcessing && !_nonStandardCreation;
647739
}
@@ -1976,4 +2068,8 @@ protected Object wrapInstantiationProblem(DeserializationContext ctxt,Throwable
19762068
}
19772069
return ctxt.handleInstantiationProblem(_beanType.getRawClass(), null, t);
19782070
}
2071+
2072+
protected boolean _isScalarType(JavaType type) {
2073+
return BeanUtil.isScalarType(type);
2074+
}
19792075
}

0 commit comments

Comments
 (0)