Skip to content

Add missing @NonNull null check for collection/map target properties - #4103

Open
zanarellidev wants to merge 2 commits into
mapstruct:mainfrom
zanarellidev:fix/nonnull-collection-target-null-check
Open

Add missing @NonNull null check for collection/map target properties#4103
zanarellidev wants to merge 2 commits into
mapstruct:mainfrom
zanarellidev:fix/nonnull-collection-target-null-check

Conversation

@zanarellidev

@zanarellidev zanarellidev commented Aug 1, 2026

Copy link
Copy Markdown

MapStruct's JSpecify null-safety documentation
(chapter-10-advanced-mapping-options.asciidoc,
"Property-level rules") states: "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."

This is correctly implemented for scalar/plain properties
(PropertyMapping.setterWrapperNeedsSourceNullCheck, with a passing test) but was silently
missing for collection/map-typed properties:
CollectionAssignmentBuilder.setterWrapperNeedsSourceNullCheck only ever consulted source
nullability, never target. Root cause traces to #4056/#4057, which ported only the "source
@NonNull skips check" half of the scalar behavior to collections, not the "target @NonNull
forces check" half.

Scope: the change applies wherever CollectionAssignmentBuilder wraps a setter/field/update
path, not only an explicitly @NonNull setter. Unannotated collection/map properties on
@NullMarked beans also gain guards (same as the scalar path via getSetterNullability).
Direct assignments and array mappings that were already guarded are unchanged. Update mappings
that call ExistingInstanceSetterWrapperForCollectionsAndMaps with a readable target accessor
still emit setConv(null) under default SET_TO_NULL; that matches the scalar path and is
left as-is.

A @Mapper with a source List<Integer> getNumbers() (unknown nullability) mapped via built-in
element conversion to a target void setNumbers(@NonNull List<String> numbers) generated a
setter call with no null guard: a null source silently produced target.setNumbers(null),
violating the @NonNull contract with neither a compile error nor a runtime check.

This PR mirrors the existing scalar-property implementation: adds targetJSpecifyNullability to
CollectionAssignmentBuilder, populated in PropertyMapping.assignToCollection, and reuses the
same NullabilityResolver.requiresNullCheck(source, target) helper the scalar path already uses.
The collection path now emits PROPERTYMAPPING_JSPECIFY_ADD_NULL_CHECK under verbose compilation,
matching PropertyMapping.

Generated code after the fix:

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

CollectionTargetNullCheckTest reproduces the gap and confirms the fix (null and non-null
cases, fixture comparison), following the existing nullcheck/jspecify test package conventions.

Note on #4078: still open on main. It adds a third paramNullability argument to
requiresNullCheck. Whichever PR lands second will need a rebase; at that point we should decide
whether the collection path should honor the reused-method-parameter rule too.

Per the JSpecify null-safety documentation (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. This was correctly implemented
for scalar properties (PropertyMapping.setterWrapperNeedsSourceNullCheck) but was missing for
collection/map-typed properties: CollectionAssignmentBuilder.setterWrapperNeedsSourceNullCheck
only ever consulted source nullability, never target.

Root cause traces to mapstruct#4056/mapstruct#4057, which ported only the "source @nonnull skips check" half of
the scalar behavior to collections, not the "target @nonnull forces check" half.

Mirrors the existing scalar-property implementation: adds targetJSpecifyNullability to
CollectionAssignmentBuilder, populated in PropertyMapping.assignToCollection, and reuses the
same NullabilityResolver.requiresNullCheck(source, target) helper the scalar path already uses.

Signed-off-by: zanarelli <zanarelli.dev@gmail.com>

@renechoi renechoi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran this locally on d31052d against 211b2be (JDK 21.0.9, both javac and ecj). The diagnosis holds up and the fix is correct. Measurements below, plus a few things I think are worth changing before it lands.

Verification

Control first, to check that the test actually defends the fix: the four new test files applied to unmodified main, nothing else.

CollectionTargetNullCheckTest.nullSourceListShouldNotBePassedToNonNullSetter:38
Expecting value to be false but was true
Tests run: 2, Failures: 2, Errors: 0, Skipped: 0

Same test on the PR head: Tests run: 2, Failures: 0. Full processor module on the PR head: Tests run: 3645, Failures: 0, Errors: 0, Skipped: 0.

The root-cause attribution checks out as well. git log -S sourceJSpecifyNullability -- CollectionAssignmentBuilder.java returns exactly one commit, 35334e5 (#4056 / #4057), while requiresNullCheck entered PropertyMapping earlier in 9554065 (#1243).

Blast radius is wider than the description suggests

The description shows a single List setter. I compiled a probe through both processors (one built from main, one from this branch) and diffed the generated mappers:

target write path main this PR
setter, List<Integer>@NonNull List<String> setConv( convert(…) ) guarded
setter, Map<Integer,Integer>@NonNull Map<String,String> unguarded guarded
public @NonNull List<String> field t.conv = convert(…) guarded
@NullMarked bean, unannotated List setter unguarded guarded
@MappingTarget update, target has no read accessor unguarded guarded
setter, List<String>@NonNull List<String> (direct) already guarded unchanged
String[] / Integer[]@NonNull String[] already guarded unchanged
@MappingTarget update, target has a read accessor setConv( null ) unchanged

Two rows deserve comment.

The @NullMarked row is the widest part of this change. getSetterNullability resolves an unannotated setter inside a @NullMarked scope to NON_NULL, so every collection/map property of a @NullMarked bean whose assignment is not DIRECT gains a wrapper, not just explicitly annotated ones. That matches what the scalar path already does, so I read it as intended, but it is worth saying in the description because it is much broader than "a @NonNull setter".

The last row still emits target.setConv( null ) from ExistingInstanceSetterWrapperForCollectionsAndMaps under the default SET_TO_NULL. I do not think this PR should touch it. I measured the scalar equivalent and it behaves the same way (target.setNum( null ) into a @NonNull scalar setter), so the two paths remain symmetric and this is the feature's existing boundary rather than something this change missed. Mentioning it only so it is not mistaken for a gap.

The new null check is silent

JSpecifyVerboseNoteTest describes its own purpose as making JSpecify null-check decisions "no longer silent", and the scalar path emits PROPERTYMAPPING_JSPECIFY_ADD_NULL_CHECK whenever it decides to add one. The new branch here returns jspecifyDecision but only notes the false case.

Compiling the probe with -Amapstruct.verbose=true on this branch: the five properties newly guarded above produce no JSpecify note at all. The only add-note in the entire run comes from the scalar path:

Note: -- MapStruct: JSpecify adding null check for property "arrConv": source=UNKNOWN, target=NON_NULL.

arrConv is the identical UNKNOWN → NON_NULL decision, just routed through PropertyMapping rather than CollectionAssignmentBuilder. Emitting PROPERTYMAPPING_JSPECIFY_ADD_NULL_CHECK( targetPropertyName, sourceJSpecifyNullability, targetJSpecifyNullability ) in the true branch would make the two report identically.

The new @param javadoc states the precedence backwards

 * @param targetJSpecifyNullabilityused to force a null check when the target requires
 *                                  {@code @NonNull} regardless of the source's own nullability

requiresNullCheck tests the source first and returns FALSE for a @NonNull source before it ever looks at the target, so the source wins. Measured, with a @NonNull source getter feeding the same @NonNull setter:

tSetterConv.setConv( integerListToStringList( src.getConv() ) );
Note: -- MapStruct: JSpecify skipping null check for property "conv": source is @NonNull.

The inline comment you added inside the method body has it right ("with a source that is not itself @NonNull"); only the @param text is inverted.

Test conventions in that package

Across the 20 test classes under test/nullcheck/jspecify:

  • 19 carry @IssueKey. CollectionTargetNullCheckTest is the only one without.
  • 18 register GeneratedSource; 16 of those call addComparisonToFixtureFor(...). This test registers the extension and never touches it, so the field is currently dead. (JSpecifySafetyGuardTest is the one existing precedent.) Since what is being fixed here is the shape of the generated code, the fixture comparison the neighbours use would pin if ( list != null ) directly rather than inferring it from a flag on the bean.
  • The test asserts one direction only. Nothing in it fails if the assignment were dropped entirely instead of guarded. I added the other direction locally and it passes on this branch (assertThat( target.getNumbers() ).containsExactly( "1", "2", "3" ), Tests run: 4, Failures: 0), so this is a missing guard in the test rather than a defect. JSpecifyCollectionPropertyTest next door asserts both the mapped values and the fixture.

One cross-PR interaction

#4078 changes NullabilityResolver.requiresNullCheck from two parameters to three (adding paramNullability) and updates its single call site. This PR adds a second call site for the two-parameter form. Whichever lands second needs a rebase, and at that point someone has to decide whether the collection path should also honour the reused-method-parameter rule. Raising it for ordering only, nothing to change here.

Emit PROPERTYMAPPING_JSPECIFY_ADD_NULL_CHECK in true branch; fix @PARAM javadoc; add @IssueKey, fixture comparison, non-null assertion.

Signed-off-by: zanarelli <zanarelli.dev@gmail.com>
@zanarellidev

Copy link
Copy Markdown
Author

Thanks for the thorough review. Pushed 2fab9c0 addressing the code/test items:

  • verbose PROPERTYMAPPING_JSPECIFY_ADD_NULL_CHECK on the true branch (matches scalar path)
  • @param precedence fix on targetJSpecifyNullability
  • @IssueKey, fixture comparison, and a non-null mapped-values assertion

Updated the PR description for @NullMarked scope and noted the #4078 rebase interaction. Agreed on leaving the existing-instance update SET_TO_NULL boundary unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants