Skip to content

Fix #4030 remove double getters in null checks#4064

Open
stefanos-kalantzis wants to merge 1 commit into
mapstruct:mainfrom
stefanos-kalantzis:fix-4030-remove-double-getters-in-null-checks
Open

Fix #4030 remove double getters in null checks#4064
stefanos-kalantzis wants to merge 1 commit into
mapstruct:mainfrom
stefanos-kalantzis:fix-4030-remove-double-getters-in-null-checks

Conversation

@stefanos-kalantzis

@stefanos-kalantzis stefanos-kalantzis commented Jun 5, 2026

Copy link
Copy Markdown

fixes #4030

Note: There is an edge-case considered out of scope for this PR, where source is used multiple times for different mappings. Then the generated code has multiple blocks with a different extracted variable, with incremental names. For example in DomainDtoWithNcvsAlwaysMapperImpl:

List<String> strings = source.getStrings();
if ( target.getStrings() != null ) {
    if ( source.hasStrings() ) {
        target.getStrings().clear();
        target.getStrings().addAll( strings );
    }
}
else {
    if ( source.hasStrings() ) {
        List<String> list = strings;
        target.setStrings( new LinkedHashSet<>( list ) );
    }
}
List<String> strings1 = source.getStrings();
if ( target.getLongs() != null ) {
    if ( source.hasStrings() ) {
        target.getLongs().clear();
        target.getLongs().addAll( stringListToLongSet( strings1 ) );
    }
}
else {
    if ( source.hasStrings() ) {
        target.setLongs( stringListToLongSet( strings1 ) );
    }
}

@stefanos-kalantzis stefanos-kalantzis force-pushed the fix-4030-remove-double-getters-in-null-checks branch from a50b711 to 3aa1ca6 Compare June 5, 2026 14:52
@stefanos-kalantzis stefanos-kalantzis force-pushed the fix-4030-remove-double-getters-in-null-checks branch from 3aa1ca6 to d727a50 Compare June 5, 2026 15:11
@stefanos-kalantzis

Copy link
Copy Markdown
Author

hi @filiphr, I went through the changes again and I think it should be ok to merge.

@filiphr filiphr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for tackling #4030, @stefanos-kalantzis.

I have one concern and one test gap (details in the inline comments):

Behavioral change for custom presence-check methods (hasX() / isSetX()). For a SuffixPresenceCheck (e.g. source.hasStrings()), the value getter source.getStrings() is now hoisted into an unconditional local variable, so it runs even when the presence check returns false. See the regenerated _913/DomainDtoWithPresenceCheckMapperImpl:

// before: getStrings() only ran inside the guard
if ( source.hasStrings() ) {
    target.getStrings().addAll( source.getStrings() );
}
// after: getStrings() runs unconditionally
List<String> strings = source.getStrings();
if ( source.hasStrings() ) {
    target.getStrings().addAll( strings );
}

This is the JAXB isSetFoo() / getFoo() idiom - calling getFoo() unconditionally lazily instantiates the source collection, defeating the purpose of the presence check. It's the "expensive / side-effecting getter" case #4030 is meant to protect, and it yields no benefit here since the presence-check path never had a double call to begin with. Root cause: presenceCheckAllowsLocalVar only excludes MethodReferencePresenceCheck; SuffixPresenceCheck falls through to return true. Could we extend the guard to skip hoisting when the presence check doesn't read the source RHS value?

No behavioral test. The PR relies entirely on regenerated golden fixtures, which is not bad per se. However, I think it would be good if we can add some behaviour tests. e.g. for the scenario I mentioned before with the use of the presence checker perhaps?

Comment on lines -44 to 47
if ( source.getZonedDateTime().isPresent() ) {
Optional<ZonedDateTime> zonedDateTime = source.getZonedDateTime();
if ( zonedDateTime.isPresent() ) {
target.setZonedDateTime( dateTimeFormatter_dd_MM_yyyy_HH_mm_z_01894582668.format( source.getZonedDateTime().get() ) );
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We are still using source.getZonedDateTime().get() in the optional conversion. I believe this should be zonedDateTime.get() instead of source.getZonedDateTime()

}
}

private boolean presenceCheckAllowsLocalVar(PresenceCheck presenceCheck) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔴 Root cause of the unconditional-getter regression. This guard only excludes MethodReferencePresenceCheck; every other presence-check type (notably SuffixPresenceCheck, i.e. the hasX()/isSetX() idiom) falls through to return true, so a local var is created and the getter is hoisted above the if (source.hasX()) guard — executing it even when the check is false.

The principle here is right ("only hoist when the presence check consumes the source RHS value"), but it's enforced incompletely. Suggest returning false for presence checks that don't read the RHS — SuffixPresenceCheck, and please review JavaExpressionPresenceCheck plus Negate/All/Any wrappers around non-RHS checks. Local-var hoisting is safe/beneficial only for NullPresenceCheck, value-based OptionalPresenceCheck, and MethodReferencePresenceCheck bound to the source RHS.

Minor: this method encodes the subtlest decision in the PR — worth a short comment explaining the intent.

sourceReference,
sourceRHS
) );
if ( presenceCheckAllowsLocalVar( sourceRHS.getSourcePresenceCheckerReference() ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is where the local var gets set for simple (non-nested) properties. Because presenceCheckAllowsLocalVar returns true for SuffixPresenceCheck (see comment on the method below), a hasX()-guarded property ends up with an unconditionally-evaluated local var. The _913 fixtures show the effect.

ctx.getTypeFactory(),
targetAccessorType.isFieldAssignment()
);
if ( result.getSourceType().isPrimitive() ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In the mutable-collection update path (ExistingInstanceSetterWrapperForCollectionsAndMaps), the local var is only reset for primitive source types. So a List<String> fed by a hasX() presence check keeps the hoisted local var and is read unconditionally (this is the exact branch the _913 update mappers hit). If the fix in presenceCheckAllowsLocalVar lands, this primitive-only reset may be redundant — but as it stands today it doesn't cover the regression.

return;
}

List<String> strings = source.getStrings();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Concrete evidence of the regression: source.getStrings() is now invoked here unconditionally, before the if ( source.hasStrings() ) guards below. Previously it only ran inside those guards. For JAXB-style isSetX()/lazy/expensive getters this is an observable behavior change (and a counting-getter test would fail here).


Assignment result = rightHandSide;
if ( sourceReference == null || !sourceReference.isNested() ) {
// For non-nested properties, reset the local var name: the adder wrapper handles

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor: the comment says "For non-nested properties", but the guard also covers sourceReference == null (expression/constant mappings). Consider "For non-nested (or absent) source references". Optionally note why nested is excluded — nested references keep their local var to avoid re-invoking the forged accessor method.

@hduelme hduelme left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@stefanos-kalantzis Thanks for your contribution. The generated code looks much cleaner now.

I left a few comments regarding the generated code style. It looks like too many local variables are being generated now.

Additionally, I'm not fully convinced about setting setSourceLocalVarName to null later on. It feels a bit like we're using mutable state to undo a previous change, which makes the flow harder to follow. Could we avoid setting it in the first place, or model this differently?

Comment on lines +71 to 72
List<String> list = strings;
target.setStrings( new LinkedHashSet<>( list ) );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The assignment is now redundant and should be inlined.

Comment on lines +103 to +112
List<Wheel> wheels = car.getWheels();
if ( mappingTarget.getWheels() != null ) {
List<WheelDto> list = wheelListToWheelDtoList( car.getWheels() );
List<WheelDto> list = wheelListToWheelDtoList( wheels );
if ( list != null ) {
mappingTarget.getWheels().clear();
mappingTarget.getWheels().addAll( list );
}
}
else {
List<WheelDto> list = wheelListToWheelDtoList( car.getWheels() );
List<WheelDto> list = wheelListToWheelDtoList( wheels );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think that the variable is needed, as wheels is only used in two separated branches.

Optional<String> title = song.getTitle();
if ( song.hasTitle() ) {
targetAggregate.setSongTitle( song.getTitle().get() );
targetAggregate.setSongTitle( title.get() );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The variable is not needed as song as an check method (hasTitle()).

Comment on lines +179 to +181
if ( result.getSourceType().isPrimitive() ) {
result.setSourceLocalVarName( null );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are primitives excluded?

Comment on lines +833 to +840
private boolean presenceCheckAllowsLocalVar(PresenceCheck presenceCheck) {
if ( presenceCheck instanceof MethodReferencePresenceCheck ) {
return ((MethodReferencePresenceCheck) presenceCheck).getMethodReference().getParameterBindings()
.stream().anyMatch( ParameterBinding::isForSourceRhs );
}
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is checked here?

@hduelme

hduelme commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Oops, it looks like @filiphr and I started reviewing at the same time. I didn't check for existing review comments before submitting mine, so there may be some overlap.

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.

Getters can be computed twice when nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS

3 participants