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
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public class CollectionAssignmentBuilder {
private NullValueCheckStrategyGem nvcs;
private NullValuePropertyMappingStrategyGem nvpms;
private NullabilityResolver.Nullability sourceJSpecifyNullability = NullabilityResolver.Nullability.UNKNOWN;
private NullabilityResolver.Nullability targetJSpecifyNullability = NullabilityResolver.Nullability.UNKNOWN;

public CollectionAssignmentBuilder mappingBuilderContext(MappingBuilderContext ctx) {
this.ctx = ctx;
Expand Down Expand Up @@ -145,6 +146,23 @@ public CollectionAssignmentBuilder sourceJSpecifyNullability(
return this;
}

/**
* @param targetJSpecifyNullability the JSpecify nullability of the target write accessor (setter parameter
* or field); when the source is not {@code @NonNull}, a {@code @NonNull}
* target can still require a null check (source nullability is checked
* first by {@link NullabilityResolver#requiresNullCheck})
*
* @return this builder for chaining
*/
public CollectionAssignmentBuilder targetJSpecifyNullability(
NullabilityResolver.Nullability targetJSpecifyNullability
) {
this.targetJSpecifyNullability = targetJSpecifyNullability != null
? targetJSpecifyNullability
: NullabilityResolver.Nullability.UNKNOWN;
return this;
}

public Assignment build() {
Assignment result = assignment;

Expand Down Expand Up @@ -273,12 +291,21 @@ 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 ) {
// JSpecify: source @NonNull means the value is guaranteed non-null, skip the wrapper;
// target @NonNull (with a source that is not itself @NonNull) always requires the wrapper,
// mirroring PropertyMapping#setterWrapperNeedsSourceNullCheck for plain properties.
Boolean jspecifyDecision = ctx.getNullabilityResolver().requiresNullCheck(
sourceJSpecifyNullability, targetJSpecifyNullability );
if ( jspecifyDecision != null ) {
ctx.getMessager().note( 2,
Message.PROPERTYMAPPING_JSPECIFY_SKIP_NULL_CHECK_NON_NULL_SOURCE,
targetPropertyName );
return false;
jspecifyDecision
? Message.PROPERTYMAPPING_JSPECIFY_ADD_NULL_CHECK
: Message.PROPERTYMAPPING_JSPECIFY_SKIP_NULL_CHECK,
targetPropertyName,
sourceJSpecifyNullability,
targetJSpecifyNullability
);
return jspecifyDecision;
}

if ( rhs.getSourcePresenceCheckerReference() != null ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,8 @@ private Assignment assignToCollection(Type targetType, AccessorType targetAccess
.nullValueCheckStrategy( hasDefaultValueOrDefaultExpression() ? ALWAYS : nvcs )
.nullValuePropertyMappingStrategy( nvpms )
.sourceJSpecifyNullability( getSourceJSpecifyNullability() )
.targetJSpecifyNullability( ctx.getNullabilityResolver().getSetterNullability(
targetWriteAccessor.getElement(), this::targetDeclaringTypeIsNullMarked ) )
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* 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.nullcheck.jspecify.collectiontarget;

import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

@Mapper
public interface CollectionTargetNullCheckMapper {

CollectionTargetNullCheckMapper INSTANCE = Mappers.getMapper( CollectionTargetNullCheckMapper.class );

CollectionTargetNullCheckTargetBean map(CollectionTargetNullCheckSourceBean source);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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.nullcheck.jspecify.collectiontarget;

import java.util.List;

/**
* Deliberately unannotated (unknown JSpecify nullability) collection getter, requiring an
* element-type conversion (Integer -> String) on the way to the target so the assignment is
* not a DIRECT one.
*/
public class CollectionTargetNullCheckSourceBean {

private List<Integer> numbers;

public List<Integer> getNumbers() {
return numbers;
}

public void setNumbers(List<Integer> numbers) {
this.numbers = numbers;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.nullcheck.jspecify.collectiontarget;

import java.util.List;

import org.jspecify.annotations.NonNull;

public class CollectionTargetNullCheckTargetBean {

private List<String> numbers;
private boolean numbersSet;

public List<String> getNumbers() {
return numbers;
}

public void setNumbers(@NonNull List<String> numbers) {
this.numbersSet = true;
this.numbers = numbers;
}

public boolean isNumbersSet() {
return numbersSet;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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.nullcheck.jspecify.collectiontarget;

import java.util.Arrays;

import org.junit.jupiter.api.extension.RegisterExtension;
import org.mapstruct.ap.testutil.IssueKey;
import org.mapstruct.ap.testutil.ProcessorTest;
import org.mapstruct.ap.testutil.WithClasses;
import org.mapstruct.ap.testutil.WithJSpecify;
import org.mapstruct.ap.testutil.runner.GeneratedSource;

import static org.assertj.core.api.Assertions.assertThat;

@IssueKey( "4057" )
@WithJSpecify
@WithClasses( {
CollectionTargetNullCheckSourceBean.class,
CollectionTargetNullCheckTargetBean.class,
CollectionTargetNullCheckMapper.class
} )
class CollectionTargetNullCheckTest {

@RegisterExtension
final GeneratedSource generatedSource = new GeneratedSource();

@ProcessorTest
void nullSourceListShouldNotBePassedToNonNullSetter() {
generatedSource.addComparisonToFixtureFor( CollectionTargetNullCheckMapper.class );

CollectionTargetNullCheckSourceBean source = new CollectionTargetNullCheckSourceBean();
source.setNumbers( null );

CollectionTargetNullCheckTargetBean target = CollectionTargetNullCheckMapper.INSTANCE.map( source );

// Per docs (chapter-10-advanced-mapping-options.asciidoc, "Property-level rules"):
// "If the target is @NonNull (and the source is not @NonNull), a null check is always
// added so the target's contract is not violated."
// setNumbers() is annotated @NonNull, so it should never be invoked when the source is null.
assertThat( target.isNumbersSet() ).isFalse();
}

@ProcessorTest
void nonNullSourceListShouldBeMappedToNonNullSetter() {
generatedSource.addComparisonToFixtureFor( CollectionTargetNullCheckMapper.class );

CollectionTargetNullCheckSourceBean source = new CollectionTargetNullCheckSourceBean();
source.setNumbers( Arrays.asList( 1, 2, 3 ) );

CollectionTargetNullCheckTargetBean target = CollectionTargetNullCheckMapper.INSTANCE.map( source );

assertThat( target.isNumbersSet() ).isTrue();
assertThat( target.getNumbers() ).containsExactly( "1", "2", "3" );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.nullcheck.jspecify.collectiontarget;

import java.util.ArrayList;
import java.util.List;
import javax.annotation.processing.Generated;

@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2026-08-01T21:05:20+0200",
comments = "version: , compiler: javac, environment: Java 25 (Eclipse Adoptium)"
)
public class CollectionTargetNullCheckMapperImpl implements CollectionTargetNullCheckMapper {

@Override
public CollectionTargetNullCheckTargetBean map(CollectionTargetNullCheckSourceBean source) {
if ( source == null ) {
return null;
}

CollectionTargetNullCheckTargetBean collectionTargetNullCheckTargetBean = new CollectionTargetNullCheckTargetBean();

List<String> list = integerListToStringList( source.getNumbers() );
if ( list != null ) {
collectionTargetNullCheckTargetBean.setNumbers( list );
}

return collectionTargetNullCheckTargetBean;
}

protected List<String> integerListToStringList(List<Integer> list) {
if ( list == null ) {
return null;
}

List<String> list1 = new ArrayList<>( list.size() );
for ( Integer integer : list ) {
list1.add( String.valueOf( integer ) );
}

return list1;
}
}