#2688: Access to the target property name by ivlcic · Pull Request #2834 · mapstruct/mapstruct

Implemented

Access to the target property name #2688
As agreed in discussion: #2831

Rationale

Currently there is no option to do run-time filtering based on target property name.

This comes very handy when mapping larger object graphs from ORM frameworks, to block
unnecessary loading from database and reuse mapping code.

With this feature you can for instance block further mapping in runtime based on some @Context variable state and reuse mapper code without having to foresee every possible property mapping combination.

Implementation

Implementation introduces support for new presence check method parameter annotated with @TargetPropertyName.

It is implemented with minimum impact to current code base, so it might be in architecture/elegance sense problematic.

It introduces no changes any current features; it's only an addition.

Unit tests are provided in org/mapstruct/ap/test/conditional.

Example 1

@Mapper
public interface MyMapper {

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

    Employee map(EmployeeDto employee);

    @Condition
    default <T> boolean isNotEmpty(Collection<T> collection, @TargetPropertyName String propName) {
        if ( "addresses".equalsIgnoreCase( propName ) ) {
            return false;
        }
        return collection != null && !collection.isEmpty();
    }

    @Condition
    default boolean isNotBlank(String value, @TargetPropertyName String propName) {
        if ( propName.equalsIgnoreCase( "lastName" ) ) {
            return false;
        }
        return value != null && !value.trim().isEmpty();
    }
}

Example 2

@Mapper
public interface MyMapper {

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

    Employee map(EmployeeDto employee, @Context PresenceUtils utils);

    Address map(AddressDto addressDto, @Context PresenceUtils utils);

    class PresenceUtils {
        Set<String> visited = new LinkedHashSet<>();

        @Condition
        public boolean decide(String value, @TargetPropertyName String propName) {
            return true; //decide if continue mapping
        }
    }
}

Complex Example

interface Entity {}
interface Dto {}

class Employee implements Entity { // setters and getters ommited
    String name;
    Employee boss;
    Address address;
    List<Employee> subordinates;
    String title;
}

class Address implements Entity { // setters and getters ommited
    String street;
}

class EmployeeDto implements Dto { // default constructor, setters and getters ommited
    String name;
    EmployeeDto boss;
    AddressDto address;
    List<EmployeeDto> subordinates;
    String title;

    public EmployeeDto(String name, EmployeeDto boss, AddressDto address) {
        this.name = name;
        this.boss = boss;
        this.address = address;
    }

    public EmployeeDto(String name, AddressDto address, List<EmployeeDto> subordinates) {
        this.name = name;
        this.address = address;
        this.subordinates = subordinates;
    }
}

class AddressDto implements Dto { // default constructor, setters and getters ommited
    String street;
    
    public AddressDto(String street) {
        this.street = street;
    }
}

class PathSegment {
    String name;
    boolean cycleDetected;

    public PathSegment(String name, boolean cycleDetected) {
        this.name = name;
        this.cycleDetected = cycleDetected;
    }
}

class CollectionPathSegment extends PathSegment {
    int index;

    public CollectionPathSegment(String name, boolean cycleDetected) {
        super( name, cycleDetected );
    }
}

class CollectionElementPathSegment extends PathSegment {
    int index;
    public CollectionElementPathSegment(String name, int index, boolean cycleDetected) {
        super( name, cycleDetected );
        this.index = index;
    }
}

class MapContext {
    Set<String> ignoreProps = new HashSet<>();
    Set<Integer> mapped = new HashSet<>();
    Deque<PathSegment> path = new LinkedList<>();
    Deque<String> visited = new LinkedList<>();
    String lastProp = null;

    private String pathToString() {
        StringBuilder strPath = new StringBuilder();
        for ( PathSegment segment : path ) {
            if ( segment instanceof CollectionElementPathSegment ) {
                strPath.append( segment.name );
            } else {
                if ( strPath.length() > 0 ) {
                    strPath.append( "." );
                }
                strPath.append( segment.name );
            }
        }
        if (path.isEmpty()) {
            return lastProp;
        }
        strPath.append( "." );
        strPath.append( lastProp );
        return strPath.toString();
    }

    @Condition
    public boolean collect(@TargetPropertyName String propName) {
        PathSegment segment = path.peekLast();
        if ( ignoreProps.contains( propName ) || (segment != null && segment.cycleDetected)) {
            return false;
        }
        this.lastProp = propName;
        this.visited.offerLast( pathToString() );
        return true;
    }
}

interface FirmMapper<E extends Entity, D extends Dto> {

    E map(D dto, @Context MapContext ctx);

    @BeforeMapping
    default void before(Object dto, @Context MapContext ctx) {
        if ( dto == null ) {
            return;
        }
        boolean cycleDetected = !ctx.mapped.add( dto.hashCode() );

        if ( ctx.lastProp == null ) {
            return;
        }
        if ( dto instanceof Collection ) {
            ctx.path.offerLast( new CollectionPathSegment( ctx.lastProp, cycleDetected ) );
        } else {
            PathSegment segment = ctx.path.peekLast();
            if ( segment instanceof CollectionPathSegment ) {
                ctx.path.offerLast(
                        new CollectionElementPathSegment(
                                "[" + ( (CollectionPathSegment) segment ).index + "]",
                                ( (CollectionPathSegment) segment ).index,
                                cycleDetected
                        )
                );
                ( (CollectionPathSegment) segment ).index++;
            } else {
                ctx.path.offerLast( new PathSegment( ctx.lastProp, cycleDetected ) );
            }
        }
    }

    @AfterMapping
    default void after(Object dto, @Context MapContext ctx) {
        ctx.path.pollLast();
        PathSegment segment = ctx.path.peekLast();
        if ( segment != null ) {
            ctx.lastProp = segment.name;
        } else {
            ctx.lastProp = null;
        }
    }
}

@Mapper
interface EmployeeMapper extends FirmMapper<Employee, EmployeeDto> {
    EmployeeMapper INSTANCE = Mappers.getMapper( EmployeeMapper.class );
}

@Mapper
interface AddressMapper extends FirmMapper<Address, AddressDto> {
    AddressMapper INSTANCE = Mappers.getMapper( AddressMapper.class );
}

// ... more mappers

public void test) {
    EmployeeMapper mapper = EmployeeMapper.INSTANCE;
    MapContext context = new MapContext();
    //context.ignoreProps.add( "boss" ); // glob - like exclude / include could be implemented

    EmployeeDto bossDto = new EmployeeDto( "Boss", new AddressDto("Testing st. 18"), new ArrayList<>() );
    bossDto.subordinates.add( new EmployeeDto( "Employee1", bossDto, new AddressDto("Street 1") ) );
    bossDto.subordinates.add( new EmployeeDto( "Employee2", bossDto, new AddressDto("Street 2") ) );
    bossDto.subordinates.add( new EmployeeDto( "Employee3", bossDto, new AddressDto("Street 3") ) );

    Employee boss = mapper.map( bossDto, context );
    assertThat( List.of(
            "name",
            "boss",
            "address",
            "address.street",
            "subordinates",
            "subordinates[0].name",
            "subordinates[0].boss",
            "subordinates[0].address",
            "subordinates[0].address.street",
            "subordinates[0].subordinates",
            "subordinates[0].title",
            "subordinates[1].name",
            "subordinates[1].boss",
            "subordinates[1].address",
            "subordinates[1].address.street",
            "subordinates[1].subordinates",
            "subordinates[1].title",
            "subordinates[2].name",
            "subordinates[2].boss",
            "subordinates[2].address",
            "subordinates[2].address.street",
            "subordinates[2].subordinates",
            "subordinates[2].title",
            "title"
    ) ).isEqualTo( context.visited );
}