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
3 changes: 3 additions & 0 deletions NEXT_RELEASE_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* Add support for JSpecify nullness annotations (#1243) - MapStruct now detects `@NonNull`, `@Nullable`, `@NullMarked` and `@NullUnmarked` from `org.jspecify.annotations` to control null check generation:
- Source `@NonNull` skips null checks; target `@NonNull` always adds them
- `@NonNull` source parameters skip the method-level null guard
- `@NonNull` source on collection-typed property mappings skips the wrapping null guard
- Container mapping methods (`Iterable`, `Map`, `Stream`, arrays) honor JSpecify on their source parameter
- `@NonNull` mapping-method return type implies `NullValueMappingStrategy.RETURN_DEFAULT` semantics across bean, iterable, map and stream mapping methods
- `@NullMarked` / `@NullUnmarked` scope is resolved by walking method → class → outer class → package
- Compile error when mapping a potentially nullable source to a `@NonNull` constructor parameter without a `defaultValue`
- Can be disabled with the `mapstruct.disableJSpecify` compiler option
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@ The existing safety guards (`defaultValue` / `defaultExpression`, primitive unbo
==== Method-level source parameter

When the source parameter of a mapping method is annotated `@NonNull` (directly or via a `@NullMarked` scope), MapStruct skips the method-level null guard, since the caller is contractually obliged to pass a non-null value.
This rule applies uniformly to bean, iterable, map, and stream mapping methods.

==== Method-level return type

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.

==== Constructor parameter constraint

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
import org.mapstruct.ap.internal.model.source.selector.SelectedMethod;
import org.mapstruct.ap.internal.model.source.selector.SelectionCriteria;
import org.mapstruct.ap.internal.util.Message;
import org.mapstruct.ap.internal.util.NullabilityResolver;
import org.mapstruct.ap.internal.util.Strings;
import org.mapstruct.ap.internal.util.accessor.Accessor;
import org.mapstruct.ap.internal.util.accessor.AccessorType;
Expand Down Expand Up @@ -371,7 +372,7 @@ else if ( !method.isUpdateMethod() ) {
reportErrorForUnusedSourceParameters();
reportErrorForRedundantIgnoredSourceProperties();

// mapNullToDefault
// mapNullToDefault — JSpecify @NonNull return forces RETURN_DEFAULT to avoid generating `return null`.
boolean mapNullToDefault = method.getOptions()
.getBeanMapping()
.getNullValueMappingStrategy()
Expand Down Expand Up @@ -507,6 +508,23 @@ else if ( !method.isUpdateMethod() ) {
}
}

// JSpecify: @NonNull return forces RETURN_DEFAULT to avoid generating `return null`.
// Only applies when there are nullable source parameters (presence checks exist), since without them
// the template never generates a `return null` block in the first place.
if ( !mapNullToDefault
&& !method.isUpdateMethod()
&& !method.getReturnType().isVoid()
&& !presenceChecksByParameter.isEmpty() ) {
NullabilityResolver.Nullability returnNullability = ctx.getNullabilityResolver().getNullability(
method.getExecutable(),
() -> ctx.getTypeFactory().getType( ctx.getMapperTypeElement().asType() ).isNullMarked() );
if ( returnNullability == NullabilityResolver.Nullability.NON_NULL ) {
ctx.getMessager().note( 2,
Message.MAPPING_METHOD_JSPECIFY_FORCE_RETURN_DEFAULT,
method.getName() );
mapNullToDefault = true;
}
}

return new BeanMappingMethod(
method,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.mapstruct.ap.internal.model.source.Method;
import org.mapstruct.ap.internal.model.source.SelectionParameters;
import org.mapstruct.ap.internal.util.Message;
import org.mapstruct.ap.internal.util.NullabilityResolver;
import org.mapstruct.ap.internal.util.accessor.Accessor;
import org.mapstruct.ap.internal.util.accessor.AccessorType;

Expand Down Expand Up @@ -73,6 +74,7 @@ public class CollectionAssignmentBuilder {
private SourceRHS sourceRHS;
private NullValueCheckStrategyGem nvcs;
private NullValuePropertyMappingStrategyGem nvpms;
private NullabilityResolver.Nullability sourceJSpecifyNullability = NullabilityResolver.Nullability.UNKNOWN;

public CollectionAssignmentBuilder mappingBuilderContext(MappingBuilderContext ctx) {
this.ctx = ctx;
Expand Down Expand Up @@ -134,6 +136,15 @@ public CollectionAssignmentBuilder nullValuePropertyMappingStrategy( NullValuePr
return this;
}

public CollectionAssignmentBuilder sourceJSpecifyNullability(
NullabilityResolver.Nullability sourceJSpecifyNullability
) {
this.sourceJSpecifyNullability = sourceJSpecifyNullability != null
? sourceJSpecifyNullability
: NullabilityResolver.Nullability.UNKNOWN;
return this;
}

public Assignment build() {
Assignment result = assignment;

Expand Down Expand Up @@ -262,6 +273,14 @@ private boolean canBeMappedOrDirectlyAssigned(Assignment result) {
* @return whether to include a null / presence check or not
*/
private boolean setterWrapperNeedsSourceNullCheck(Assignment rhs) {
// JSpecify: source @NonNull means the value is guaranteed non-null, skip the wrapper
if ( sourceJSpecifyNullability == NullabilityResolver.Nullability.NON_NULL ) {
ctx.getMessager().note( 2,
Message.PROPERTYMAPPING_JSPECIFY_SKIP_NULL_CHECK_NON_NULL_SOURCE,
targetPropertyName );
return false;
}

if ( rhs.getSourcePresenceCheckerReference() != null ) {
// If there is a source presence check then we should do a null check
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import org.mapstruct.ap.internal.model.common.Parameter;
import org.mapstruct.ap.internal.model.common.PresenceCheck;
import org.mapstruct.ap.internal.model.common.Type;
import org.mapstruct.ap.internal.model.presence.NullPresenceCheck;
import org.mapstruct.ap.internal.model.source.Method;
import org.mapstruct.ap.internal.model.source.SelectionParameters;
import org.mapstruct.ap.internal.util.Strings;
Expand All @@ -36,12 +35,14 @@ public abstract class ContainerMappingMethod extends NormalTypeMappingMethod {
private final PresenceCheck sourceParameterPresenceCheck;
private IterableCreation iterableCreation;

//CHECKSTYLE:OFF
ContainerMappingMethod(Method method, List<Annotation> annotations,
Collection<String> existingVariables, Assignment parameterAssignment,
MethodReference factoryMethod, boolean mapNullToDefault, String loopVariableName,
List<LifecycleCallbackMethodReference> beforeMappingReferences,
List<LifecycleCallbackMethodReference> afterMappingReferences,
SelectionParameters selectionParameters) {
SelectionParameters selectionParameters, PresenceCheck sourceParameterPresenceCheck) {
//CHECKSTYLE:ON
super( method, annotations, existingVariables, factoryMethod, mapNullToDefault, beforeMappingReferences,
afterMappingReferences );
this.elementAssignment = parameterAssignment;
Expand All @@ -64,7 +65,7 @@ public abstract class ContainerMappingMethod extends NormalTypeMappingMethod {
}

this.sourceParameter = sourceParameter;
this.sourceParameterPresenceCheck = new NullPresenceCheck( this.sourceParameter.getName() );
this.sourceParameterPresenceCheck = sourceParameterPresenceCheck;

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@

import org.mapstruct.ap.internal.model.common.Assignment;
import org.mapstruct.ap.internal.model.common.FormattingParameters;
import org.mapstruct.ap.internal.model.common.Parameter;
import org.mapstruct.ap.internal.model.common.PresenceCheck;
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.model.source.SelectionParameters;
import org.mapstruct.ap.internal.model.source.selector.SelectionCriteria;
import org.mapstruct.ap.internal.util.Message;
import org.mapstruct.ap.internal.util.NullabilityResolver;
import org.mapstruct.ap.internal.util.Strings;

import static org.mapstruct.ap.internal.util.Collections.first;
Expand Down Expand Up @@ -124,11 +127,24 @@ public final M build() {
}
assignment = getWrapper( assignment, method );

// mapNullToDefault
// mapNullToDefault — JSpecify @NonNull return forces RETURN_DEFAULT to avoid generating `return null`.
boolean mapNullToDefault = method.getOptions()
.getIterableMapping()
.getNullValueMappingStrategy()
.isReturnDefault();
if ( !mapNullToDefault
&& !method.isUpdateMethod()
&& !method.getReturnType().isVoid() ) {
NullabilityResolver.Nullability returnNullability = ctx.getNullabilityResolver().getNullability(
method.getExecutable(),
() -> ctx.getTypeFactory().getType( ctx.getMapperTypeElement().asType() ).isNullMarked() );
if ( returnNullability == NullabilityResolver.Nullability.NON_NULL ) {
ctx.getMessager().note( 2,
Message.MAPPING_METHOD_JSPECIFY_FORCE_RETURN_DEFAULT,
method.getName() );
mapNullToDefault = true;
}
}

MethodReference factoryMethod = null;
if ( !method.isUpdateMethod() ) {
Expand All @@ -151,6 +167,11 @@ public final M build() {
existingVariables
);

// Resolve presence check via JSpecify-aware resolver — returns null when source is @NonNull.
Parameter sourceParam = first( method.getSourceParameters() );
PresenceCheck sourceParameterPresenceCheck =
PresenceCheckMethodResolver.getPresenceCheckForSourceParameter( method, null, sourceParam, ctx );

return instantiateMappingMethod(
method,
existingVariables,
Expand All @@ -160,7 +181,8 @@ public final M build() {
loopVariableName,
beforeMappingMethods,
afterMappingMethods,
selectionParameters
selectionParameters,
sourceParameterPresenceCheck
);
}

Expand All @@ -177,7 +199,7 @@ protected abstract M instantiateMappingMethod(Method method, Collection<String>
boolean mapNullToDefault, String loopVariableName,
List<LifecycleCallbackMethodReference> beforeMappingMethods,
List<LifecycleCallbackMethodReference> afterMappingMethods,
SelectionParameters selectionParameters);
SelectionParameters selectionParameters, PresenceCheck sourceParameterPresenceCheck);

protected abstract Type getElementType(Type parameterType);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.mapstruct.ap.internal.model.assignment.LocalVarWrapper;
import org.mapstruct.ap.internal.model.assignment.SetterWrapper;
import org.mapstruct.ap.internal.model.common.Assignment;
import org.mapstruct.ap.internal.model.common.PresenceCheck;
import org.mapstruct.ap.internal.model.common.Type;
import org.mapstruct.ap.internal.model.source.Method;
import org.mapstruct.ap.internal.model.source.SelectionParameters;
Expand Down Expand Up @@ -54,7 +55,8 @@ protected Assignment getWrapper(Assignment assignment, Method method) {
protected IterableMappingMethod instantiateMappingMethod(Method method, Collection<String> existingVariables,
Assignment assignment, MethodReference factoryMethod, boolean mapNullToDefault, String loopVariableName,
List<LifecycleCallbackMethodReference> beforeMappingMethods,
List<LifecycleCallbackMethodReference> afterMappingMethods, SelectionParameters selectionParameters) {
List<LifecycleCallbackMethodReference> afterMappingMethods, SelectionParameters selectionParameters,
PresenceCheck sourceParameterPresenceCheck) {
return new IterableMappingMethod(
method,
getMethodAnnotations(),
Expand All @@ -65,17 +67,20 @@ protected IterableMappingMethod instantiateMappingMethod(Method method, Collecti
loopVariableName,
beforeMappingMethods,
afterMappingMethods,
selectionParameters
selectionParameters,
sourceParameterPresenceCheck
);
}
}

//CHECKSTYLE:OFF
private IterableMappingMethod(Method method, List<Annotation> annotations,
Collection<String> existingVariables, Assignment parameterAssignment,
MethodReference factoryMethod, boolean mapNullToDefault, String loopVariableName,
List<LifecycleCallbackMethodReference> beforeMappingReferences,
List<LifecycleCallbackMethodReference> afterMappingReferences,
SelectionParameters selectionParameters) {
SelectionParameters selectionParameters, PresenceCheck sourceParameterPresenceCheck) {
//CHECKSTYLE:ON
super(
method,
annotations,
Expand All @@ -86,7 +91,8 @@ private IterableMappingMethod(Method method, List<Annotation> annotations,
loopVariableName,
beforeMappingReferences,
afterMappingReferences,
selectionParameters
selectionParameters,
sourceParameterPresenceCheck
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
import org.mapstruct.ap.internal.model.common.PresenceCheck;
import org.mapstruct.ap.internal.model.common.SourceRHS;
import org.mapstruct.ap.internal.model.common.Type;
import org.mapstruct.ap.internal.model.presence.NullPresenceCheck;
import org.mapstruct.ap.internal.model.source.Method;
import org.mapstruct.ap.internal.model.source.SelectionParameters;
import org.mapstruct.ap.internal.model.source.selector.SelectionCriteria;
import org.mapstruct.ap.internal.util.Message;
import org.mapstruct.ap.internal.util.NullabilityResolver;
import org.mapstruct.ap.internal.util.Strings;

import static org.mapstruct.ap.internal.util.Collections.first;
Expand Down Expand Up @@ -182,9 +182,22 @@ public MapMappingMethod build() {
ctx.getMessager().note( 2, Message.MAPMAPPING_SELECT_VALUE_NOTE, valueAssignment );
}

// mapNullToDefault
// mapNullToDefault — JSpecify @NonNull return forces RETURN_DEFAULT to avoid generating `return null`.
boolean mapNullToDefault =
method.getOptions().getMapMapping().getNullValueMappingStrategy().isReturnDefault();
if ( !mapNullToDefault
&& !method.isUpdateMethod()
&& !method.getReturnType().isVoid() ) {
NullabilityResolver.Nullability returnNullability = ctx.getNullabilityResolver().getNullability(
method.getExecutable(),
() -> ctx.getTypeFactory().getType( ctx.getMapperTypeElement().asType() ).isNullMarked() );
if ( returnNullability == NullabilityResolver.Nullability.NON_NULL ) {
ctx.getMessager().note( 2,
Message.MAPPING_METHOD_JSPECIFY_FORCE_RETURN_DEFAULT,
method.getName() );
mapNullToDefault = true;
}
}

MethodReference factoryMethod = null;
if ( !method.isUpdateMethod() ) {
Expand All @@ -201,6 +214,10 @@ public MapMappingMethod build() {
List<LifecycleCallbackMethodReference> afterMappingMethods =
LifecycleMethodResolver.afterMappingMethods( method, null, ctx, existingVariables );

Parameter sourceParam = first( method.getSourceParameters() );
PresenceCheck sourceParameterPresenceCheck =
PresenceCheckMethodResolver.getPresenceCheckForSourceParameter( method, null, sourceParam, ctx );

return new MapMappingMethod(
method,
getMethodAnnotations(),
Expand All @@ -210,7 +227,8 @@ public MapMappingMethod build() {
factoryMethod,
mapNullToDefault,
beforeMappingMethods,
afterMappingMethods
afterMappingMethods,
sourceParameterPresenceCheck
);
}

Expand All @@ -229,11 +247,14 @@ protected boolean shouldUsePropertyNamesInHistory() {

}

//CHECKSTYLE:OFF
private MapMappingMethod(Method method, List<Annotation> annotations,
Collection<String> existingVariableNames, Assignment keyAssignment,
Assignment valueAssignment, MethodReference factoryMethod, boolean mapNullToDefault,
List<LifecycleCallbackMethodReference> beforeMappingReferences,
List<LifecycleCallbackMethodReference> afterMappingReferences) {
List<LifecycleCallbackMethodReference> afterMappingReferences,
PresenceCheck sourceParameterPresenceCheck) {
//CHECKSTYLE:ON
super( method, annotations, existingVariableNames, factoryMethod, mapNullToDefault, beforeMappingReferences,
afterMappingReferences );

Expand All @@ -252,7 +273,7 @@ private MapMappingMethod(Method method, List<Annotation> annotations,
}

this.sourceParameter = sourceParameter;
this.sourceParameterPresenceCheck = new NullPresenceCheck( this.sourceParameter.getName() );
this.sourceParameterPresenceCheck = sourceParameterPresenceCheck;
}

public Parameter getSourceParameter() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ private Assignment assignToCollection(Type targetType, AccessorType targetAccess
.assignment( rhs )
.nullValueCheckStrategy( hasDefaultValueOrDefaultExpression() ? ALWAYS : nvcs )
.nullValuePropertyMappingStrategy( nvpms )
.sourceJSpecifyNullability( getSourceJSpecifyNullability() )
.build();
}

Expand Down
Loading
Loading