Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions NEXT_RELEASE_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Bugs

* Propagate JSpecify `@Nullable` from the mapper method to the generated implementation's return type and parameters (#4076)

### Documentation

### Build
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,43 @@ This rule applies uniformly to bean, iterable, map, and stream mapping methods.

When the return type of a mapping method is `@NonNull` (directly or via a `@NullMarked` scope), MapStruct forces `NullValueMappingStrategy.RETURN_DEFAULT` semantics. The generated method returns a default-constructed target (bean methods), an empty collection (`Iterable` / array mappings), an empty map (`Map` mappings), or `Stream.empty()` (stream mappings) rather than `null`, so the return contract is never violated. This rule applies regardless of the explicit `NullValueMappingStrategy` setting.

==== Nullability in the generated implementation

The generated implementation reproduces the `@Nullable` annotations of the mapping method it implements, so the implementation keeps the nullability contract of its interface and IDEs do not report the overriding method as missing nullness information:

.Mapping method with JSpecify nullness annotations
====
[source, java, linenums]
[subs="verbatim,attributes"]
----
@Mapper
public interface CarMapper {

@Nullable
CarDto carToCarDto(Car car, @Nullable Options options);
}
----
====

The following implementation is generated:

.Generated implementation keeping the nullability contract
====
[source, java, linenums]
[subs="verbatim,attributes"]
----
public class CarMapperImpl implements CarMapper {

@Override
public @Nullable CarDto carToCarDto(Car car, @Nullable Options options) {
// ...
}
}
----
====

Only an explicit `@Nullable` is reproduced. A `@NonNull` nullability is deliberately not emitted: within a `@NullMarked` scope it is the implied default, so writing it out would add noise to every generated signature without changing the contract. Methods that MapStruct generates as internal helpers (rather than as an implementation of a mapper method) are not annotated, since they do not have to match an inherited signature.

==== Constructor parameter constraint

If a property mapping would assign a potentially `null` source value to a `@NonNull` constructor parameter, MapStruct raises a *compilation error*. Neither inserting a null check (which would leave the variable at `null` and violate the contract) nor passing the value through is safe. Provide a `defaultValue` or `defaultExpression` on the `@Mapping` to satisfy the parameter when the source is absent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.lang.model.element.Element;

import org.mapstruct.ap.internal.model.beanmapping.MappingReferences;
import org.mapstruct.ap.internal.model.common.Assignment;
import org.mapstruct.ap.internal.model.common.Parameter;
import org.mapstruct.ap.internal.model.common.SourceRHS;
import org.mapstruct.ap.internal.model.common.Type;
import org.mapstruct.ap.internal.model.source.Method;
import org.mapstruct.ap.internal.util.JSpecifyConstants;
import org.mapstruct.ap.internal.util.NullabilityResolver;
import org.mapstruct.ap.internal.util.Strings;

/**
Expand Down Expand Up @@ -137,4 +141,47 @@ public List<Annotation> getMethodAnnotations() {
return annotations;
}

/**
* Propagates the JSpecify {@code @Nullable} annotations of the overridden mapper method onto the generated
* implementation's return type and source parameters.
* <p>
* Only explicit {@code @Nullable} annotations are reproduced. A {@code @NonNull} nullability is left out on
* purpose: within a {@code @NullMarked} scope it is the implied default, so emitting it would add noise to every
* generated signature without changing the contract.
* <p>
* Nothing is propagated for methods that do not override a mapper method (e.g. forged methods), since those are
* generated helpers that do not have to match an inherited signature.
*
* @param mappingMethod the mapping method to annotate
*/
protected void propagateNullability(MappingMethod mappingMethod) {
if ( method instanceof ForgedMethod || !method.overridesMethod() ) {
return;
}

Type nullableType = null;

if ( isJSpecifyNullable( method.getExecutable() ) ) {
nullableType = getNullableAnnotationType();
mappingMethod.setNullableReturnTypeAnnotationType( nullableType );
}

for ( Parameter parameter : mappingMethod.getParameters() ) {
if ( parameter.getElement() != null && isJSpecifyNullable( parameter.getElement() ) ) {
if ( nullableType == null ) {
nullableType = getNullableAnnotationType();
}
parameter.setNullableAnnotationType( nullableType );
}
}
}

private boolean isJSpecifyNullable(Element element) {
return ctx.getNullabilityInMapperScope( element ) == NullabilityResolver.Nullability.NULLABLE;
}

private Type getNullableAnnotationType() {
return ctx.getTypeFactory().getType( JSpecifyConstants.NULLABLE_FQN );
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ else if ( !method.isUpdateMethod() ) {
mapNullToDefault = true;
}

return new BeanMappingMethod(
BeanMappingMethod beanMappingMethod = new BeanMappingMethod(
method,
getMethodAnnotations(),
existingVariableNames,
Expand All @@ -544,6 +544,10 @@ else if ( !method.isUpdateMethod() ) {
subclassExhaustiveExceptionType,
sourceParametersReassignments
);

propagateNullability( beanMappingMethod );

return beanMappingMethod;
}

private void keepMappingReferencesUsingTarget(List<LifecycleCallbackMethodReference> references, Type type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public final M build() {
PresenceCheck sourceParameterPresenceCheck =
PresenceCheckMethodResolver.getPresenceCheckForSourceParameter( method, null, sourceParam, ctx );

return instantiateMappingMethod(
M mappingMethod = instantiateMappingMethod(
method,
existingVariables,
assignment,
Expand All @@ -178,6 +178,10 @@ public final M build() {
selectionParameters,
sourceParameterPresenceCheck
);

propagateNullability( mappingMethod );

return mappingMethod;
}

private Assignment forge(SourceRHS sourceRHS, Type sourceType, Type targetType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ public abstract class MappingMethod extends GeneratedTypeMethod {
private final List<LifecycleCallbackMethodReference> beforeMappingReferencesWithoutMappingTarget;
private final List<LifecycleCallbackMethodReference> afterMappingReferences;

/**
* The type of the JSpecify {@code @Nullable} annotation to emit in front of this method's return type, or
* {@code null} when nothing should be emitted. Only set for methods that override a mapper method, so that the
* generated implementation keeps the nullability contract of the interface it implements.
*/
private Type nullableReturnTypeAnnotationType;

/**
* constructor to be overloaded when local variable names are required prior to calling this constructor. (e.g. for
* property mappings). It is supposed to be initialized with at least the parameter names.
Expand Down Expand Up @@ -131,6 +138,19 @@ public Type getReturnType() {
return returnType;
}

/**
* @return the type of the JSpecify {@code @Nullable} annotation to emit in front of this method's return type,
* or {@code null} when the return type's nullability must not be reproduced in the generated
* implementation
*/
public Type getNullableReturnTypeAnnotationType() {
return nullableReturnTypeAnnotationType;
}

public void setNullableReturnTypeAnnotationType(Type nullableReturnTypeAnnotationType) {
this.nullableReturnTypeAnnotationType = nullableReturnTypeAnnotationType;
}

public Accessibility getAccessibility() {
return accessibility;
}
Expand All @@ -148,11 +168,15 @@ public Set<Type> getImportTypes() {
Set<Type> types = new HashSet<>();

for ( Parameter param : parameters ) {
types.addAll( param.getType().getImportTypes() );
types.addAll( param.getImportTypes() );
}

types.addAll( getReturnType().getImportTypes() );

if ( nullableReturnTypeAnnotationType != null ) {
types.add( nullableReturnTypeAnnotationType );
}

for ( Type type : thrownTypes ) {
types.addAll( type.getImportTypes() );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ public class Parameter extends ModelElement {

private final boolean varArgs;

/**
* The type of the JSpecify {@code @Nullable} annotation to emit in front of this parameter's type, or
* {@code null} when nothing should be emitted. Only set for parameters of methods that override a mapper
* method, so that the generated implementation keeps the nullability contract of the interface it implements.
*/
private Type nullableAnnotationType;

private Parameter(Element element, Type type, boolean varArgs) {
this.element = element;
this.name = element.getSimpleName().toString();
Expand Down Expand Up @@ -113,9 +120,25 @@ private String format() {
+ "%s " + name;
}

/**
* @return the type of the JSpecify {@code @Nullable} annotation to emit in front of this parameter's type, or
* {@code null} when the parameter's nullability must not be reproduced in the generated implementation
*/
public Type getNullableAnnotationType() {
return nullableAnnotationType;
}

public void setNullableAnnotationType(Type nullableAnnotationType) {
this.nullableAnnotationType = nullableAnnotationType;
}

@Override
public Set<Type> getImportTypes() {
return Collections.asSet( type );
if ( nullableAnnotationType == null ) {
return Collections.asSet( type );
}

return Collections.asSet( type, nullableAnnotationType );
}

public boolean isTargetType() {
Expand Down Expand Up @@ -147,7 +170,7 @@ public boolean isSourceParameter() {
}

public Parameter withName(String name) {
return new Parameter(
Parameter parameter = new Parameter(
name,
this.name,
type,
Expand All @@ -158,6 +181,8 @@ public Parameter withName(String name) {
targetPropertyName,
varArgs
);
parameter.nullableAnnotationType = nullableAnnotationType;
return parameter;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<#list annotations as annotation>
<#nt><@includeModel object=annotation/>
</#list>
<#lt>${accessibility.keyword} <@includeModel object=returnType/> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#lt>${accessibility.keyword} <#if nullableReturnTypeAnnotationType??>@<@includeModel object=nullableReturnTypeAnnotationType/> </#if><@includeModel object=returnType/> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#assign targetType = resultType />
<#if !existingInstanceMapping>
<#assign targetType = returnTypeToConstruct />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<#list annotations as annotation>
<#nt><@includeModel object=annotation/>
</#list>
<#lt>${accessibility.keyword} <@includeModel object=returnType/> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#lt>${accessibility.keyword} <#if nullableReturnTypeAnnotationType??>@<@includeModel object=nullableReturnTypeAnnotationType/> </#if><@includeModel object=returnType/> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#list beforeMappingReferencesWithoutMappingTarget as callback>
<@includeModel object=callback targetBeanName=resultName targetType=resultType/>
<#if !callback_has_next>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<#list annotations as annotation>
<#nt><@includeModel object=annotation/>
</#list>
<#lt>${accessibility.keyword} <@includeModel object=returnType /> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#lt>${accessibility.keyword} <#if nullableReturnTypeAnnotationType??>@<@includeModel object=nullableReturnTypeAnnotationType/> </#if><@includeModel object=returnType /> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#list beforeMappingReferencesWithoutMappingTarget as callback>
<@includeModel object=callback targetBeanName=resultName targetType=resultType/>
<#if !callback_has_next>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<#list annotations as annotation>
<#nt><@includeModel object=annotation/>
</#list>
<#lt>${accessibility.keyword} <@includeModel object=returnType/> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#lt>${accessibility.keyword} <#if nullableReturnTypeAnnotationType??>@<@includeModel object=nullableReturnTypeAnnotationType/> </#if><@includeModel object=returnType/> ${name}(<#list parameters as param><@includeModel object=param/><#if param_has_next>, </#if></#list>)<@throws/> {
<#--TODO does it even make sense to do a callback if the result is a Stream, as they are immutable-->
<#list beforeMappingReferencesWithoutMappingTarget as callback>
<@includeModel object=callback targetBeanName=resultName targetType=resultType/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

-->
<#-- @ftlvariable name="" type="org.mapstruct.ap.internal.model.common.Parameter" -->
<@includeModel object=type asVarArgs=varArgs/> ${name}
<#if nullableAnnotationType??>@<@includeModel object=nullableAnnotationType/> </#if><@includeModel object=type asVarArgs=varArgs/> ${name}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.bugs._4076;

import java.util.List;

import org.jspecify.annotations.Nullable;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;

/**
* Covers the container mapping methods (iterable / map / stream) and update methods, which are built through
* {@code ContainerMappingMethodBuilder} rather than {@code BeanMappingMethod.Builder}.
*/
@Mapper
public interface Issue4076IterableMapper {

@Nullable
List<String> mapList(@Nullable List<String> source);

void update(@Nullable Source source, @MappingTarget Target target);

class Source {

private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

class Target {

private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}
}
Loading