Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bc7b5f9
ISSUE-512: Add @JsonWrapped annotation for grouping scalar fields int…
sri-adarsh-kumar Feb 26, 2026
93391ac
Merge branch '3.x' into feature/512-json-wrapped-annotation
sri-adarsh-kumar Feb 26, 2026
3594e63
Merge branch '3.x' into feature/512-json-wrapped-annotation
sri-adarsh-kumar Mar 1, 2026
d3535d1
ISSUE-512: Refactor @JsonWrapped implementation - simplify wrapper co…
sri-adarsh-kumar Mar 1, 2026
40393a0
feat: lift @JsonWrapped scalar-only restriction — support POJOs, coll…
sri-adarsh-kumar Mar 1, 2026
e37433c
Merge branch '3.x' into feature/512-json-wrapped-annotation
sri-adarsh-kumar Mar 1, 2026
250307f
Merge branch '3.x' into feature/512-json-wrapped-annotation
cowtowncoder Mar 1, 2026
6f6770d
Merge branch '3.x' into feature/512-json-wrapped-annotation
cowtowncoder Mar 17, 2026
ffed70c
Merge branch '3.x' into feature/512-json-wrapped-annotation
cowtowncoder Mar 23, 2026
c2856c2
Merge branch '3.x' into feature/512-json-wrapped-annotation
cowtowncoder Apr 2, 2026
fb1186a
Merge branch '3.x' into feature/512-json-wrapped-annotation
cowtowncoder Apr 18, 2026
ef64089
Merge branch '3.x' into feature/512-json-wrapped-annotation
sri-adarsh-kumar Apr 21, 2026
d70bda3
Merge branch '3.x' into feature/512-json-wrapped-annotation
cowtowncoder Apr 24, 2026
1d00c61
Merge branch '3.x' into feature/512-json-wrapped-annotation
cowtowncoder Apr 28, 2026
35b0099
Move @JsonWrapped annotation to jackson-annotations
sri-adarsh-kumar Apr 29, 2026
9264503
Merge branch 'feature/512-json-wrapped-annotation' of github.com:sri-…
sri-adarsh-kumar Apr 29, 2026
78e09c6
Merge branch '3.x' into feature/512-json-wrapped-annotation
sri-adarsh-kumar Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,39 @@ Note that use of a "creator method" (`@JsonCreator` with `@JsonProperty` annotat
can mix and match properties from constructor/factory method with ones that
are set via setters or directly using fields.

### Annotations: wrapping properties

`@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.

```java
public class Gene {
public String name;

@JsonWrapped("chr") public String chromosome;
@JsonWrapped("chr") public int position;
}
```

Serializing a `Gene` instance produces:

```json
{
"name" : "BRCA1",
"chr" : {
"chromosome" : "17",
"position" : 43044295
}
}
```

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

Note: non-scalar types (POJOs, collections, maps, arrays) are supported for baseline
serialization/deserialization. Existing interaction limitations around `@JsonView`,
`@JsonFilter`, and `@JsonInclude` on inner wrapped fields still apply — see the
[`@JsonWrapped` Javadoc](https://github.com/FasterXML/jackson-annotations/blob/2.x/src/main/java/com/fasterxml/jackson/annotation/JsonWrapped.java)
for the full list of constraints.

## Tutorial: fancier stuff, conversions

One useful (but not very widely known) feature of Jackson is its ability
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/tools/jackson/databind/AnnotationIntrospector.java
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,22 @@ public Object findTypeIdResolver(MapperConfig<?> config, Annotated ann) {
*/
public NameTransformer findUnwrappingNameTransformer(MapperConfig<?> config, AnnotatedMember member) { return null; }

/**
* Method for finding the wrapped group name for a member annotated
* with {@code @JsonWrapped}. Three-value return contract:
* <ul>
* <li>{@code null} — annotation not present; secondary introspector may supply a value</li>
* <li>{@code ""} (empty string) — annotation present but explicitly disabled;
* secondary introspector must NOT override</li>
* <li>non-empty string — active wrapper name</li>
* </ul>
*
* @since 3.1
*/
public String findWrappedGroupName(MapperConfig<?> config, AnnotatedMember member) {
return null;
}

/**
* Method called to check whether given property is marked to
* be ignored. This is used to determine whether to ignore
Expand Down
106 changes: 106 additions & 0 deletions src/main/java/tools/jackson/databind/deser/bean/BeanDeserializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,14 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) t
}
if (_nonStandardCreation) {
if (_unwrappedPropertyHandler != null) {
if (_wrappedPropertyHandler != null) {
return deserializeWithUnwrappedAndWrapped(p, ctxt); // both present
}
return deserializeWithUnwrapped(p, ctxt);
}
if (_wrappedPropertyHandler != null) {
return deserializeWithWrapped(p, ctxt); // only wrapped
}
if (_externalTypeIdHandler != null) {
return deserializeWithExternalTypeId(p, ctxt);
}
Expand Down Expand Up @@ -1403,6 +1409,106 @@ protected Object _deserializeWithExternalTypeId(JsonParser p, DeserializationCon
return ext.complete(p, ctxt, bean);
}

/**
* Deserialization method for beans that have {@code @JsonWrapped} properties.
* Similar to standard property-by-property deserialization, but recognizes
* wrapper field names and dispatches to WrappedPropertyHandler.
*/
protected Object deserializeWithWrapped(JsonParser p, DeserializationContext ctxt)
throws JacksonException
{
if (_delegateDeserializer != null) {
return _valueInstantiator.createUsingDelegate(ctxt,
_delegateDeserializer.deserialize(p, ctxt));
}
if (_propertyBasedCreator != null) {
// For MVP, wrapped properties on creator params are rejected during resolve()
// so this shouldn't be reached with wrapped creator properties.
// Fall through to non-default creation.
return deserializeFromObjectUsingNonDefault(p, ctxt);
}
final Object bean = _valueInstantiator.createUsingDefault(ctxt);
p.assignCurrentValue(bean);

if (_injectables != null) {
injectValues(ctxt, bean);
}
final Class<?> activeView = _needViewProcesing ? ctxt.getActiveView() : null;

for (int ix = p.currentNameMatch(_propNameMatcher); ;
ix = p.nextNameMatch(_propNameMatcher)) {
if (ix >= 0) {
// Known regular property
p.nextToken();
SettableBeanProperty prop = _propsByIndex[ix];
if (activeView != null && !prop.visibleInView(activeView)) {
p.skipChildren();
continue;
}
try {
prop.deserializeAndSet(p, ctxt, bean);
} catch (Exception e) {
throw wrapAndThrow(e, bean, prop.getName(), ctxt);
}
continue;
}
if (ix == PropertyNameMatcher.MATCH_END_OBJECT) {
break;
}
if (ix == PropertyNameMatcher.MATCH_ODD_TOKEN) {
// error handling for odd tokens
return _handleUnexpectedWithin(p, ctxt, bean);
}
// MATCH_UNKNOWN_NAME — check if it's a wrapper name
String propName = p.currentName();
p.nextToken();

if (_wrappedPropertyHandler.hasWrapperName(propName)) {
_wrappedPropertyHandler.handleWrappedObject(p, ctxt, bean, propName);
continue;
}

// Standard unknown property handling
if (_ignorableProps != null && _ignorableProps.contains(propName)) {
handleIgnoredProperty(p, ctxt, bean, propName);
continue;
}
if (_anySetter != null) {
try {
_anySetter.deserializeAndSet(p, ctxt, bean, propName);
} catch (Exception e) {
throw wrapAndThrow(e, bean, propName, ctxt);
}
continue;
}
handleUnknownProperty(p, ctxt, bean, propName);
}
return bean;
}

/**
* Deserialization method for beans that have <em>both</em> {@code @JsonUnwrapped} and
* {@code @JsonWrapped} properties.
*
* <p><strong>MVP limitation:</strong> This method currently delegates entirely to
* {@link #deserializeWithWrapped}, which means {@code @JsonUnwrapped} properties are
* <em>silently ignored</em> — they are removed from {@code _beanProperties} during
* {@code resolve()} and {@code _unwrappedPropertyHandler} is never invoked here.
* A full implementation must interleave the unwrapped-property token-buffering logic
* from {@link #deserializeWithUnwrapped} with the inline wrapper dispatch logic from
* {@link #deserializeWithWrapped}.
*
* <p>TODO: Implement proper combined handling (post-MVP).
*
* @see #deserializeWithWrapped
* @see #deserializeWithUnwrapped
*/
protected Object deserializeWithUnwrappedAndWrapped(JsonParser p, DeserializationContext ctxt)
throws JacksonException
{
return deserializeWithWrapped(p, ctxt);
}

@SuppressWarnings("resource")
protected Object deserializeUsingPropertyBasedWithExternalTypeId(JsonParser p, DeserializationContext ctxt)
throws JacksonException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ public abstract class BeanDeserializerBase
*/
protected UnwrappedPropertyHandler _unwrappedPropertyHandler;

/**
* Handler for properties annotated with {@code @JsonWrapped}, if any.
*
* @since 3.1
*/
protected WrappedPropertyHandler _wrappedPropertyHandler;

/**
* Handler that we need if any of properties uses external
* type id.
Expand Down Expand Up @@ -322,6 +329,7 @@ protected BeanDeserializerBase(BeanDeserializerBase src, boolean ignoreAllUnknow

_nonStandardCreation = src._nonStandardCreation;
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
_wrappedPropertyHandler = src._wrappedPropertyHandler;
_needViewProcesing = src._needViewProcesing;
_serializationShape = src._serializationShape;

Expand Down Expand Up @@ -388,6 +396,7 @@ protected BeanDeserializerBase(BeanDeserializerBase src, ObjectIdReader oir)

_nonStandardCreation = src._nonStandardCreation;
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
_wrappedPropertyHandler = src._wrappedPropertyHandler;
_needViewProcesing = src._needViewProcesing;
_serializationShape = src._serializationShape;

Expand Down Expand Up @@ -435,6 +444,7 @@ public BeanDeserializerBase(BeanDeserializerBase src,

_nonStandardCreation = src._nonStandardCreation;
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
_wrappedPropertyHandler = src._wrappedPropertyHandler;
_needViewProcesing = src._needViewProcesing;
_serializationShape = src._serializationShape;

Expand Down Expand Up @@ -469,6 +479,7 @@ protected BeanDeserializerBase(BeanDeserializerBase src, BeanPropertyMap beanPro

_nonStandardCreation = src._nonStandardCreation;
_unwrappedPropertyHandler = src._unwrappedPropertyHandler;
_wrappedPropertyHandler = src._wrappedPropertyHandler;
_needViewProcesing = src._needViewProcesing;
_serializationShape = src._serializationShape;

Expand Down Expand Up @@ -698,6 +709,75 @@ public void resolve(DeserializationContext ctxt)
} else {
_unwrappedPropertyHandler = null;
}

// Detect @JsonWrapped properties
WrappedPropertyHandler wrapped = null;
AnnotationIntrospector intr = ctxt.getAnnotationIntrospector();
if (intr != null) {
// Iterate a copy since we'll modify _beanProperties
List<SettableBeanProperty> allProps = new ArrayList<>();
for (SettableBeanProperty prop : _beanProperties) {
allProps.add(prop);
}

// First pass: collect wrapped properties and validate
Map<String, List<SettableBeanProperty>> groups = new LinkedHashMap<>();
Set<SettableBeanProperty> wrappedPropSet = new HashSet<>();
for (SettableBeanProperty prop : allProps) {
AnnotatedMember member = prop.getMember();
if (member == null) {
continue;
}
String wrapperName = intr.findWrappedGroupName(ctxt.getConfig(), member);
if (wrapperName == null || wrapperName.isEmpty()) {
continue; // not wrapped, or explicitly disabled via @JsonWrapped("")
}
wrappedPropSet.add(prop);

// Validate: creator parameter
if ((prop instanceof CreatorProperty) && ((CreatorProperty) prop).getCreatorIndex() >= 0) {
ctxt.reportBadDefinition(handledType(),
String.format("@JsonWrapped on creator parameter '%s' is not supported",
prop.getName()));
}

groups.computeIfAbsent(wrapperName, k -> new ArrayList<>()).add(prop);
}

if (!groups.isEmpty()) {
// Validate: name conflicts (wrapper name vs non-wrapped property name)
Set<String> wrapperNames = groups.keySet();
for (SettableBeanProperty prop : allProps) {
if (wrappedPropSet.contains(prop)) continue; // this is a wrapped property, skip
if (wrapperNames.contains(prop.getName())) {
ctxt.reportBadDefinition(handledType(),
String.format("Wrapper name '%s' conflicts with existing property '%s'",
prop.getName(), prop.getName()));
}
}

// Build handler and remove wrapped properties from bean properties
wrapped = new WrappedPropertyHandler();
for (Map.Entry<String, List<SettableBeanProperty>> entry : groups.entrySet()) {
for (SettableBeanProperty prop : entry.getValue()) {
wrapped.addProperty(entry.getKey(), prop);
_beanProperties.remove(prop);
}
}
}
}

if (wrapped != null) {
if (_unwrappedPropertyHandler != null) {
ctxt.reportBadDefinition(handledType(),
String.format("Cannot use both @JsonWrapped and @JsonUnwrapped on the same bean '%s': "
+ "combined deserialization is not supported (post-MVP limitation). "
+ "Use one or the other.", ClassUtil.classNameOf(handledType())));
}
_wrappedPropertyHandler = wrapped;
_nonStandardCreation = true;
}

// [databind#2039]: combination of unwrapped and external type id not (yet) supported
if (_unwrappedPropertyHandler != null && _externalTypeIdHandler != null) {
ctxt.reportBadDefinition(_beanType, String.format(
Expand Down Expand Up @@ -2196,4 +2276,5 @@ protected Object wrapInstantiationProblem(DeserializationContext ctxt,Throwable
}
return ctxt.handleInstantiationProblem(_beanType.getRawClass(), null, t);
}

}
Loading
Loading