Enable run-time "filtering" based also on property name in @Conditional · mapstruct/mapstruct · Discussion #2831

Hi, all!

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 while reusing the mapping code.

I have implemented this feature with additional (@TargetPropertyName) annotated parameter in conditional / presence check methods.

I would like to make a pull request if this feature can be accepted.

For instance:

@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();
    }
}

Or even more complex example with passed @Context method parameter where run-time decisions based on target property name could be made:

@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
        }
    }
}

And more interesting / real-life example where glob like pattern filtering could be applied:

interface Entity {}
interface Dto {}

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

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

class EmployeeDto implements Dto { //default constructor, getters and setters 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, getters and setters ommited
    String street;

    public AddressDto(String street) {
        this.street = street;
    }
}

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

    @Condition
    public boolean collectAndFilter(@TargetPropertyName String propName) {
        if ( ignoreProps.contains( propName ) ) {
            return false;
        }
        this.lastProp = propName;
        this.path.offerLast( this.lastProp );
        this.visited.offerLast( String.join( ".", this.path ) );
        this.path.pollLast();
        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 (ctx.lastProp != null && dto != null) {
            ctx.path.offerLast( ctx.lastProp );
        }
    }

    @AfterMapping
    default void after(Object dto, @Context MapContext ctx) {
        ctx.path.pollLast();
        ctx.lastProp = ctx.path.peekLast();
    }
}

@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 main(String args[]) {
    EmployeeMapper mapper = EmployeeMapper.INSTANCE;
    MapContext context = new MapContext();
    context.ignoreProps.add( "boss" ); //could be extended to glob like features

    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 );
    System.out.printf( "Visited property name paths: \n%s\n", String.join( ", \n", context.visited ) );
    //Visited property name paths:
    //name,
    //address,
    //address.street,
    //subordinates,
    //subordinates.subordinates.name,
    //subordinates.subordinates.address,
    //subordinates.subordinates.address.street,
    //subordinates.subordinates.subordinates,
    //subordinates.subordinates.title,
    //subordinates.subordinates.name,
    //subordinates.subordinates.address,
    //subordinates.subordinates.address.street,
    //subordinates.subordinates.subordinates,
    //subordinates.subordinates.title,
    //subordinates.subordinates.name,
    //subordinates.subordinates.address,
    //subordinates.subordinates.address.street,
    //subordinates.subordinates.subordinates,
    //subordinates.subordinates.title,
    //title
}