Search code examples
propertiesnullpointerexceptiontargetmapstruct

unmapped target property and mapstruct calls constructor with null parameter


I'm exploring mapstruct to map JPA entities and DTO objects. Entities and DTOs have abstract base classes that contain id and version fields that I'd like to keep private so that they can not be modified (public getter, no setter for both types). I made a most simple reproducer to demonstrate the idea. Abstract Base class has a private field name. To copy the field values back and forth Base defines a constructor that has a Base parameter. The constructor picks the private field from the parameter and assigns it to it's own private field:

package de.ruu.lab.map.read_only_field_in_base;

public abstract class Base
{
    private String name;

    public    Base(String name) { this.name = name; }
    protected Base(Base source) { name = source.name; }

    public String getName() { return name; }
}

These are the subclasses of Base:

package de.ruu.lab.map.read_only_field_in_base;

import de.ruu.lab.map.read_only_field_in_base.SimpleMapper.Default;

public class Source extends Base
{
    public Source(String name) { super(name); }
    @Default
    public Source(Base   base) { super(base); }
}
package de.ruu.lab.map.read_only_field_in_base;

import de.ruu.lab.map.read_only_field_in_base.SimpleMapper.Default;

public class Target extends Base
{
    public Target(String name) { super(name); }
    @Default
    public Target(Base   base) { super(base); }
}

I have to annotate the default constructor to resolve constructor ambiguity for mapstruct. The mapper looks like this:

package de.ruu.lab.map.read_only_field_in_base;

import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.RetentionPolicy.CLASS;

import java.lang.annotation.Retention;

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

@Mapper
public interface SimpleMapper
{
    SimpleMapper INSTANCE = Mappers.getMapper(SimpleMapper.class);

    Source toSource(Target target);
    Target toTarget(Source source);

    @Qualifier // make sure that this is the MapStruct qualifier annotation
    @java.lang.annotation.Target(CONSTRUCTOR)
    @Retention(CLASS)
    public @interface Default { }
}

The first problem is that mapstruct warns that there is an unmapped target property "base". What does that mean? Which target property is not mapped? Wouldn't it be possible to print the name of the property in the warning? I use eclipse as IDE, maybe the behaviour is different with other tools?

I tried annotating the mapping methods with

    @Mapping(target="name", ignore = true)

but that does not let the warning disappear.

Because mapstruct just makes a warning I hoped everything would be ok and I created a tiny test class:

package de.ruu.lab.map.read_only_field_in_base;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;

import org.junit.jupiter.api.Test;

class SimpleMapperTest
{
    @Test void shouldMapSourceToTarget()
    {
        Source source = new Source("map me");
        Target target = SimpleMapper.INSTANCE.toTarget(source);
        assertThat(target.getName(), is(source.getName()));
    }

    @Test void shouldMapTargetToSource()
    {
        Target target = new Target("map me");
        Source source = SimpleMapper.INSTANCE.toSource(target);
        assertThat(source.getName(), is(target.getName()));
    }
}

Both tests fail with a NPE because of some strange code mapstruct generated:

package de.ruu.lab.map.read_only_field_in_base;

import javax.annotation.processing.Generated;

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2022-09-18T11:08:20+0200",
    comments = "version: 1.5.2.Final, compiler: Eclipse JDT (IDE) 1.4.200.v20220802-0458, environment: Java 17.0.2 (GraalVM Community)"
)
public class SimpleMapperImpl implements SimpleMapper {

    @Override
    public Source toSource(Target target) {
        if ( target == null ) {
            return null;
        }

        Base base = null;

        Source source = new Source( base );

        return source;
    }

    @Override
    public Target toTarget(Source source) {
        if ( source == null ) {
            return null;
        }

        Base base = null;

        Target target = new Target( base );

        return target;
    }
}

Obviously code like this causes the NPE:

        Base base = null;

        Source source = new Source( base );

IMO this would be correct ("target" is the name of the method's parameter):

        Source source = new Source( target );

Maybe this can be solved in an upcoming version. Meanwhile, is there any recommendation how to deal with this now?

Thanks!


Solution

  • The reason why you are getting the warnings is the fact that MapStruct doesn't really care about the private / protected fields you have.

    When performing a mapping MapStruct looks at the setters and the constructor parameters to decide which properties need to be mapped.

    Looking at your examples you have annotated the constructor that takes Base as a default constructor. This means that from the point of view of MapStruct your objects have properties that are taking Base and thus there is the warning for the unmapped property base.

    There are 2 ways that you can do to fix this:

    Instruct MapStruct how to map to the base property

    @Mapper public interface SimpleMapper { SimpleMapper INSTANCE = Mappers.getMapper(SimpleMapper.class);

    @Mapping(target = "base", source = "target")
    Source toSource(Target target);
    
    @Mapping(target = "base", source = "source")
    Target toTarget(Source source);
    

    }

    using the @Mapping you will tell MapStruct that you want to map the source parameters to the base property.

    Annotated the constructor with the string a @Default

    Instead of annotating the constructor that takes Base as input you can annotate the constructor that takes String as a default constructor.

    This way MapStruct will look for how to map a property with the name name and thus will use the public Base#getName method to perform the mapping.


    Note: I saw that you have @Qualifier on the @Default annotation, this is not needed. The only requirement for MapStruct for the default annotation is that it needs to be named like that.