Skip to content
Merged
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 @@ -371,7 +371,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 +507,21 @@ else if ( !method.isUpdateMethod() ) {
}
}

// JSpecify: a @NonNull return forces RETURN_DEFAULT to avoid generating `return null`.
// The extra presence-check guard is bean-specific and required for correctness, not just an
// optimization: the bean template only emits a `return null` (and the presence-check wrapping that
// forcing mapNullToDefault would alter) when there are nullable source parameters. With none,
// getPresenceCheckByParameter would resolve to null in the single-source template branches.
// Container/Map/Stream methods instead gate this in their templates via `sourceParameterPresenceCheck??`,
// so they force unconditionally.
if ( !mapNullToDefault
&& !presenceChecksByParameter.isEmpty()
&& ctx.isJSpecifyNonNullReturn( method ) ) {
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,6 +13,8 @@

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;
Expand Down Expand Up @@ -124,11 +126,19 @@ public final M build() {
}
assignment = getWrapper( assignment, method );

// mapNullToDefault
// mapNullToDefault — a JSpecify @NonNull return forces RETURN_DEFAULT to avoid generating `return null`.
// Forcing is unconditional here (unlike BeanMappingMethod): when the source is @NonNull the template skips
// the whole guard block via `sourceParameterPresenceCheck??`, so the forced value is simply unused there.
boolean mapNullToDefault = method.getOptions()
.getIterableMapping()
.getNullValueMappingStrategy()
.isReturnDefault();
if ( !mapNullToDefault && ctx.isJSpecifyNonNullReturn( method ) ) {
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 +161,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 +175,8 @@ public final M build() {
loopVariableName,
beforeMappingMethods,
afterMappingMethods,
selectionParameters
selectionParameters,
sourceParameterPresenceCheck
);
}

Expand All @@ -177,7 +193,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,7 +18,6 @@
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;
Expand Down Expand Up @@ -182,9 +181,17 @@ public MapMappingMethod build() {
ctx.getMessager().note( 2, Message.MAPMAPPING_SELECT_VALUE_NOTE, valueAssignment );
}

// mapNullToDefault
// mapNullToDefault — a JSpecify @NonNull return forces RETURN_DEFAULT to avoid generating `return null`.
// Forcing is unconditional here (unlike BeanMappingMethod): when the source is @NonNull the template skips
// the whole guard block via `sourceParameterPresenceCheck??`, so the forced value is simply unused there.
boolean mapNullToDefault =
method.getOptions().getMapMapping().getNullValueMappingStrategy().isReturnDefault();
if ( !mapNullToDefault && ctx.isJSpecifyNonNullReturn( method ) ) {
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 +208,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 +221,8 @@ public MapMappingMethod build() {
factoryMethod,
mapNullToDefault,
beforeMappingMethods,
afterMappingMethods
afterMappingMethods,
sourceParameterPresenceCheck
);
}

Expand All @@ -229,11 +241,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 +267,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 @@ -13,6 +13,7 @@
import java.util.Set;
import java.util.function.Supplier;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;

import org.mapstruct.ap.internal.model.common.Assignment;
Expand Down Expand Up @@ -209,6 +210,40 @@ public NullabilityResolver getNullabilityResolver() {
return nullabilityResolver;
}

/**
* Resolves the JSpecify nullability of an element declared directly on the mapper (e.g. a mapping method's
* return type or one of its source parameters), using the mapper type's {@code @NullMarked} scope as the
* enclosing scope for unannotated elements.
*
* @param element the element declared on the mapper to inspect
*
* @return the resolved nullability ({@link NullabilityResolver.Nullability#UNKNOWN} when JSpecify is disabled)
*/
public NullabilityResolver.Nullability getNullabilityInMapperScope(Element element) {
return nullabilityResolver.getNullability(
element,
() -> typeFactory.getType( mapperTypeElement.asType() ).isNullMarked() );
}

/**
* Whether the return type of the given mapping method is JSpecify {@code @NonNull} (directly or via a
* {@code @NullMarked} scope). When it is, a mapping method must not generate {@code return null}, so callers
* force {@link org.mapstruct.ap.internal.gem.NullValueMappingStrategyGem#RETURN_DEFAULT} semantics.
* <p>
* Update methods and {@code void}-returning methods never generate a {@code return null} and are excluded.
*
* @param method the mapping method to inspect
*
* @return {@code true} if the return type is {@code @NonNull}, {@code false} otherwise
*/
public boolean isJSpecifyNonNullReturn(Method method) {
if ( method.isUpdateMethod() || method.getReturnType().isVoid() ) {
return false;
}

return getNullabilityInMapperScope( method.getExecutable() ) == NullabilityResolver.Nullability.NON_NULL;
}

public EnumMappingStrategy getEnumMappingStrategy() {
return enumMappingStrategy;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,8 @@ public static PresenceCheck getPresenceCheckForSourceParameter(
}
else if ( !sourceParameter.getType().isPrimitive() ) {
// If the source parameter is @NonNull (JSpecify), skip the null guard entirely.
// Use the mapper type for @NullMarked scope resolution since the parameter
// is declared in the mapper interface.
if ( ctx.getNullabilityResolver().getNullability(
sourceParameter.getElement(),
() -> ctx.getTypeFactory().getType( ctx.getMapperTypeElement().asType() ).isNullMarked() )
// Resolved in the mapper's @NullMarked scope since the parameter is declared in the mapper interface.
if ( ctx.getNullabilityInMapperScope( sourceParameter.getElement() )
== NullabilityResolver.Nullability.NON_NULL ) {
ctx.getMessager().note( 2,
Message.PROPERTYMAPPING_JSPECIFY_SKIP_METHOD_GUARD_NON_NULL_PARAM,
Expand Down
Loading
Loading