W tym artykule na warsztat znowu wzięty zostanie framework MapStruct, zamieszczę tutaj resztę konfiguracji i przypadków użycia kilku przydatnych adnotacji. Dla tych, którzy nie mieli nigdy do czynienia z tytułowym frameworkiem odsyłam do pierwszej części (tutaj). Zapoznanie się z nią jest niezbędne do zrozumienia tej zawartości.

Uwaga: Tak samo jak w części 1 kawałki kodu na które powinniśmy zwrócić szczególną uwagę zostaną podświetlone na żółto.

Mapowanie typów enumowych – @ValueMapping

Często przy pracy np. z web serwisami zewnętrzny model danych zdecydowanie różni się od tego używanego wewnątrz aplikacji. Zachodzi wtedy konieczność mapowania np. wartości enumowych z jednego typu na drugi. MapStruct domyślnie mapuje wartości z enuma źródłowego na wartości o tej samej nazwie w enumie docelowym.

Jednak rzadko mamy taką sytuację, że enumy te mają wszystkie takie same wartości. Jeśli by tak było nie zachodziła by konieczność ich mapowania. Z pomocą przychodzi adnotacja @ValueMapping, która działa na podobnej zasadzie jak wcześniej poznana adnotacja @Mapping. Różnica polega na tym, że nie wskazujemy nazwy zmiennej tylko nazwę stałej enumowej. Mamy także możliwość zmapowania kilku wartości źródłowych do jednej stałej w enumie docelowym. Dodatkową zaletą wykorzystania MapStruct jest możliwość zastosowania poznanej wcześniej adnotacji @InheritInverseConfiguration dzięki której małym kosztem wygenerujemy mapowanie w drugą stronę.

package demo.packages;

public enum AgreementStatus {
    NEW, WAITING, CANCELED, CLOSED, ACCEPTED, DELETED, OTHER
}

public enum ExternalAgreementStatus {
    FRESH, PENDING, REFUSED, CLOSED, OTHER
}

Uwaga: Należy zauważyć, że jeden enum posiada więcej wartości niż drugi, dlatego trzeba pamiętać żeby zmapować WSZYSTKIE wartości z enumu źródłowego. W przeciwnym razie nie będziemy w stanie skompilować aplikacji. Można to zauważyć w metodzie mapującej AgreementStatus na ExternalAgreementStatus mimo, że korzystamy z dziedziczenia konfiguracji to i tak musimy zmapować brakujące wartości. W przeciwieństwie do adnotacji @Mapper nie mamy tutaj możliwości ignorowania pól, zamiast tego możemy skorzystać z innej techniki, mapującej pozostałe pola do wartości domyślnej.
Jednak aby nie wprowadzać zbędnego zamieszania tą technikę poznamy w następnym akapicie.

@Mapper(componentModel = "spring")
public interface AgreementStatusMapper {

    @ValueMapping(source = "CLOSED",target = "CLOSED")
    @ValueMapping(source = "FRESH",target = "NEW")
    @ValueMapping(source = "PENDING",target = "WAITING")
    @ValueMapping(source = "REFUSED",target = "CANCELED")
    @ValueMapping(source = "OTHER",target = "OTHER")
    AgreementStatus mapToAgreementStatus(ExternalAgreementStatus externalAgreementStatus);

    @InheritInverseConfiguration
    @ValueMapping(target = "OTHER" , source = "ACCEPTED")
    @ValueMapping(target = "OTHER" , source = "DELETED")
    ExternalAgreementStatus mapToExternalAgreementStatus(AgreementStatus AgreementStatus);
}
import javax.annotation.Generated;
import org.springframework.stereotype.Component;

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2019-02-23T15:01:14+0100",
    comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)"
)
@Component
public class AgreementStatusMapperImpl implements AgreementStatusMapper {

    @Override
    public AgreementStatus mapToAgreementStatus(ExternalAgreementStatus externalAgreementStatus) {
        if ( externalAgreementStatus == null ) {
            return null;
        }

        AgreementStatus agreementStatus;

        switch ( externalAgreementStatus ) {
            case CLOSED: agreementStatus = AgreementStatus.CLOSED;
            break;
            case FRESH: agreementStatus = AgreementStatus.NEW;
            break;
            case PENDING: agreementStatus = AgreementStatus.WAITING;
            break;
            case REFUSED: agreementStatus = AgreementStatus.CANCELED;
            break;
            case OTHER: agreementStatus = AgreementStatus.OTHER;
            break;
            default: throw new IllegalArgumentException( "Unexpected enum constant: " + externalAgreementStatus );
        }

        return agreementStatus;
    }

    @Override
    public ExternalAgreementStatus mapToExternalAgreementStatus(AgreementStatus AgreementStatus) {
        if ( AgreementStatus == null ) {
            return null;
        }

        ExternalAgreementStatus externalAgreementStatus;

        switch ( AgreementStatus ) {
            case ACCEPTED: externalAgreementStatus = ExternalAgreementStatus.OTHER;
            break;
            case DELETED: externalAgreementStatus = ExternalAgreementStatus.OTHER;
            break;
            case CLOSED: externalAgreementStatus = ExternalAgreementStatus.CLOSED;
            break;
            case NEW: externalAgreementStatus = ExternalAgreementStatus.FRESH;
            break;
            case WAITING: externalAgreementStatus = ExternalAgreementStatus.PENDING;
            break;
            case CANCELED: externalAgreementStatus = ExternalAgreementStatus.REFUSED;
            break;
            case OTHER: externalAgreementStatus = ExternalAgreementStatus.OTHER;
            break;
            default: throw new IllegalArgumentException( "Unexpected enum constant: " + AgreementStatus );
        }

        return externalAgreementStatus;
    }
}

Jak widzimy wyżej OTHER i CLOSED nie różnią się od siebie w obu enumach, więc nie ma sensu mapować ich ręcznie. Twórcy frameworka udostępniają dwie strategie jakie możemy wykorzystać w takim przypadku: MappingConstants.ANY_REMAINING i MappingConstants.ANY_UNMAPPED. Należy jednak wiedzieć, że jest między nimi jedna istotna różnica:

  • ANY_REMAINING „@ValueMapping(source = MappingConstants.ANY_REMAINING ,target = „OTHER”)” – przy użyciu tej wartości informujemy Procesor Adnotacji o tym, że wartości o tej samej nazwie w enumie źródłowym mają mapować się bezpośrednio na takie same wartości w enumie docelowym. Zaś te dla których nie ma odwzorowania w postaci takiej samej nazwy, a także nie zdefiniowaliśmy mapowania ręcznie zostaną zmapowane na wartość podaną w parametrze target. Poniżej znajduje się konfiguracja mappera z wykorzystaniem ANY_REMAINING wraz z wygenerowanym mapowaniem.
@Mapper(componentModel = "spring")
public interface AgreementStatusMapper {

    @ValueMapping(source = "FRESH",target = "NEW")
    @ValueMapping(source = "PENDING",target = "WAITING")
    @ValueMapping(source = "REFUSED",target = "CANCELED")
    @ValueMapping(source =  MappingConstants.ANY_REMAINING ,target = "OTHER")
    AgreementStatus mapToAgreementStatus(ExternalAgreementStatus externalAgreementStatus);
}
@Component
public class AgreementStatusMapperImpl implements AgreementStatusMapper {

    @Override
    public AgreementStatus mapToAgreementStatus(ExternalAgreementStatus externalAgreementStatus) {
        if ( externalAgreementStatus == null ) {
            return null;
        }

        AgreementStatus agreementStatus;

        switch ( externalAgreementStatus ) {
            case FRESH: agreementStatus = AgreementStatus.NEW;
            break;
            case PENDING: agreementStatus = AgreementStatus.WAITING;
            break;
            case REFUSED: agreementStatus = AgreementStatus.CANCELED;
            break;
            case CLOSED: agreementStatus = AgreementStatus.CLOSED;
            break;
            case OTHER: agreementStatus = AgreementStatus.OTHER;
            break;
            default: agreementStatus = AgreementStatus.OTHER;
        }

        return agreementStatus;
    }
}
  • ANY_UNMAPPED “@ValueMapping(source = MappingConstants.ANY_UNMAPPED ,target = “OTHER“)” – za pomocą tej konstrukcji informujemy Procesor Adnotacji, że wszystkie zmapowane ręcznie przez nas wartości powinny się mapować na wartość podaną w parametrze target. Różnica polega na tym, że jesli nie zdefiniujemy mapowania dla stałych za pomocą @ValueMapping , to nawet wartości posiadające takie same nazwy w obydwu enumach zostaną zmapowane do wartości domyślnej.
    Poniżej znajduje się konfiguracja mappera z wykorzystaniem ANY_UNMAPPED wraz z wygenerowanym mapowaniem.
@Mapper(componentModel = "spring")
public interface AgreementStatusMapper {

    @ValueMapping(source = "FRESH",target = "NEW")
    @ValueMapping(source = "PENDING",target = "WAITING")
    @ValueMapping(source = "REFUSED",target = "CANCELED")
    @ValueMapping(source =  MappingConstants.ANY_UNMAPPED ,target = "OTHER")
    AgreementStatus mapToAgreementStatus(ExternalAgreementStatus externalAgreementStatus);
}
@Component
public class AgreementStatusMapperImpl implements AgreementStatusMapper {

    @Override
    public AgreementStatus mapToAgreementStatus(ExternalAgreementStatus externalAgreementStatus) {
        if ( externalAgreementStatus == null ) {
            return null;
        }

        AgreementStatus agreementStatus;

        switch ( externalAgreementStatus ) {
            case FRESH: agreementStatus = AgreementStatus.NEW;
            break;
            case PENDING: agreementStatus = AgreementStatus.WAITING;
            break;
            case REFUSED: agreementStatus = AgreementStatus.CANCELED;
            break;
            default: agreementStatus = AgreementStatus.OTHER;
        }

        return agreementStatus;
    }
}

Została jeszcze jedna wartość, którą mogli byśmy chcieć mapować w określonych sytuacjach. Aby obsłużyć wartość null i zamienić ją na stałą w enumie docelowym należy skorzystać z @ValueMapping(source = MappingConstants.NULL ,target = “OTHER“). Taka konfiguracja poinformuje procesor adnotacji aby mapowął wartości null na stałą OTHER.

Uwaga: Nie możemy używać konfiguracji ANY_REMAINING lub ANY_UNMAPPED jednocześnie. Możemy jednak łączyć je z konfiguracją MappingConstants.NULL.

Uwaga2: @InheritInverseConfiguration nie działa jeśli używamy ANY_REMAINING lub ANY_UNMAPPED w metodzie z której dziedziczymy konfigurację.

Kwalifikowanie metody mapującej za pomocą jej nazwy

@Named

Wyobraźmy sobie sytuację że w klasie ProductMapper znajdują się 2 mapowania: jedno standardowe, a drugie z pominięciem pola details. Pytanie jak zachowa się AgreementMapper gdy w jej skład wchodzi klasa Product zawierająca 2 mapowania? Gdy nie skonfigurujemy żadnych reguł framework wyrzuci błąd podczas kompilacji. Stanie się tak ponieważ MapStruct nie będzie potrafił jednoznacznie stwierdzić z którego mapowania klasy Product powinien skorzystać. Z tej sytuacji także możemy wyjść obronną ręką wykorzystując metody nazwane, oznaczone za pomocą adnotacji @Named. Wykorzystanie tej adnotacji pozwala na wykorzystanie opatrzonej przez nią metody w konfiguracji mapowania pola.
W lini 8 poniższego przykładu dodaliśmy atrybut qualifiedByName wraz z nazwą odnoszącą się do wcześniej podanej w adnotacji @Named.

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper(uses = {AttachmentMapper.class,ProductMapper.class})
public interface AgreementMapper {

    @Mapping(source = "agreementName", target = "name")
    @Mapping(source = "productDTO", target = "product", qualifiedByName = "productWithoutDescriptionNamedMethod")
    @Mapping(source = "agreementType", target = "type")
    @Mapping(source = "attachmentsDTO", target = "attachments")
    Agreement mapToAgreement(AgreementDTO agreementDTO);
}

@Mapper(componentModel = "spring")
public interface ProductMapper {

    @Mapping(source = "description", target = "details")
    Product mapToProduct(ProductDTO productDTO);

    @Mapping(ignore = true, target = "details")
    @Named("productWithoutDescriptionNamedMethod")
    Product mapToProductWithoutDescription(ProductDTO productDTO);
}
@Component
public class AgreementMapperImpl implements AgreementMapper {

    @Autowired
    private AttachmentMapper attachmentMapper;
    @Autowired
    private ProductMapper productMapper;

    @Override
    public Agreement mapToAgreement(AgreementDTO agreementDTO) {
        if ( agreementDTO == null ) {
            return null;
        }

        Agreement agreement = new Agreement();

        agreement.setName( agreementDTO.getAgreementName() );
        agreement.setProduct( productMapper.mapToProductWithoutDescription( agreementDTO.getProductDTO() ) );
        agreement.setAttachments( attachmentDTOListToAttachmentSet( agreementDTO.getAttachmentsDTO() ) );
        if ( agreementDTO.getAgreementType() != null ) {
            agreement.setType( Enum.valueOf( AgreementStatus.class, agreementDTO.getAgreementType() ) );
        }
        agreement.setId( agreementDTO.getId() );
        agreement.setConclusionDate( agreementDTO.getConclusionDate() );

        return agreement;
    }
  ...
 ...
}

@IterableMapping

W części 1 artykułu mówiliśmy, że Mapstruct zajmuje się za nas mapowaniem kolekcji typów core Java oraz tych dla których zdefiniowaliśmy ręcznie mapowanie.
Dla przypomnienia poniżej został załączony przykład:

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

import java.util.List;

@Mapper(uses = {AttachmentMapper.class})
public interface AgreementMapper {
    @Mapping(source = "agreementName", target = "name")
    @Mapping(source = "productId", target = "product.id")
    @Mapping(source = "agreementType", target = "type")
    @Mapping(source = "attachmentsDTO", target = "attachments")
    Agreement mapToAgreement(AgreementDTO agreementDTO);

    List<Agreement> mapToAgreementLis(List<AgreementDTO> agreementDTO);
}

Opisane zostało także dziedziczenie konfiguracji. Mówiliśmy, że jeśli istnieje więcej niż jedna konfiguracja to musimy wskazać z której chcemy skorzystać przy dziedziczeniu .
Było to przedstawione na przykładzie ignorowania załączników w klasie Agreement. Dodając do tego mapowanie kolekcji pojawia się pewien problem, mianowicie MapStruct nie wie której z 2 konfiguracji mapowania AgreementDTO -> Agreement użyć. W takim przypadku przy próbie kompilacji zostanie wyrzucony błąd. Sposobem na rozwiązanie problemu jest użycie adnotacji @IterableMapping połączonej z wcześniej poznaną @Named. Pozwala to na wskazanie konkretnej implementacji z której chcemy skorzystać podczas mapowania kolekcji. Jej wykorzystanie przedstawione zostało w poniższym przykładzie:

import org.mapstruct.InheritConfiguration;
import org.mapstruct.IterableMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;

import java.util.List;

@Mapper(uses = {AttachmentMapper.class})
public interface AgreementMapper {

    @Mapping(source = "agreementName", target = "name")
    @Mapping(source = "productId", target = "product.id")
    @Mapping(source = "agreementType", target = "type")
    @Mapping(source = "attachmentsDTO", target = "attachments")
    Agreement mapToAgreement(AgreementDTO agreementDTO);

    @Named("mapWithoutAttachmentsNamedMethod")
    @InheritConfiguration(name = "mapToAgreement")
    @Mapping(ignore = true, target = "attachments")
    Agreement mapToAgreementWithoutAttachments(AgreementDTO agreementDTO);

    @IterableMapping(qualifiedByName = "mapWithoutAttachmentsNamedMethod")
    List<Agreement> mapToAgreementList(List<AgreementDTO> agreementDTO);
}
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.annotation.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2019-02-25T22:31:16+0100",
    comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)"
)
@Component
public class AgreementMapperImpl implements AgreementMapper {

    @Autowired
    private AttachmentMapper attachmentMapper;

    @Override
    public Agreement mapToAgreement(AgreementDTO agreementDTO) {
        if ( agreementDTO == null ) {
            return null;
        }

        Agreement agreement = new Agreement();

        agreement.setProduct( agreementDTOToProduct( agreementDTO ) );
        agreement.setName( agreementDTO.getAgreementName() );
        agreement.setAttachments( attachmentDTOListToAttachmentSet( agreementDTO.getAttachmentsDTO() ) );
        if ( agreementDTO.getAgreementType() != null ) {
            agreement.setType( Enum.valueOf( AgreementStatus.class, agreementDTO.getAgreementType() ) );
        }
        agreement.setId( agreementDTO.getId() );
        agreement.setConclusionDate( agreementDTO.getConclusionDate() );

        return agreement;
    }

    @Override
    public Agreement mapToAgreementWithoutAttachments(AgreementDTO agreementDTO) {
        if ( agreementDTO == null ) {
            return null;
        }

        Agreement agreement = new Agreement();

        agreement.setProduct( agreementDTOToProduct1( agreementDTO ) );
        agreement.setName( agreementDTO.getAgreementName() );
        if ( agreementDTO.getAgreementType() != null ) {
            agreement.setType( Enum.valueOf( AgreementStatus.class, agreementDTO.getAgreementType() ) );
        }
        agreement.setId( agreementDTO.getId() );
        agreement.setConclusionDate( agreementDTO.getConclusionDate() );

        return agreement;
    }

    @Override
    public List<Agreement> mapToAgreementList(List<AgreementDTO> agreementDTO) {
        if ( agreementDTO == null ) {
            return null;
        }

        List<Agreement> list = new ArrayList<Agreement>( agreementDTO.size() );
        for ( AgreementDTO agreementDTO1 : agreementDTO ) {
            list.add( mapToAgreementWithoutAttachments( agreementDTO1 ) );
        }

        return list;
    }
    ...
  ...
}

Kwalifikowanie metody mapującej za pomocą własnej Adnotacji

Istnieje jeszcze jeden sposób ręcznego kwalifikowania metod mianowicie wykorzystanie qualifiedBy zamiast qualifiedByName. Sposób ten wykorzystuje ręcznie napisane przez nas adnotacje. W skrócie w miejsce adnotacji @Named wstawiamy własnoręcznie stworzoną adnotację. przykład poniżej:

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper(uses = {AttachmentMapper.class,ProductMapper.class})
public interface AgreementMapper {

    @Mapping(source = "agreementName", target = "name")
    @Mapping(source = "productDTO", target = "product", qualifiedBy = ProductWithoutDescription.class)
    @Mapping(source = "agreementType", target = "type")
    @Mapping(source = "attachmentsDTO", target = "attachments")
    Agreement mapToAgreement(AgreementDTO agreementDTO);
}

@Mapper(componentModel = "spring")
public interface ProductMapper {

    @Mapping(source = "description", target = "details")
    Product mapToProduct(ProductDTO productDTO);

    @Mapping(ignore = true, target = "details")
    @ProductWithoutDescription
    Product mapToProductWithoutDescription(ProductDTO productDTO);
}
import org.mapstruct.Qualifier;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface ProductWithoutDescription {
}

Ten sam zabieg można wykorzystać tworząc adnotację @MapWithoutAttachments, którą opatrzymy metodę mapującą mapToAgreementWithoutAttachments oraz dodając ją do @IterableMapping(qualifiedBy = MapWithoutAttachments.class”).

Sposób z adnotacją może być wygodniejszy ze względu na wyszukanie jej użycia w kodzie lub refaktor. Z drugiej strony jeśli zdecydujemy się na adnotacje w dużym projekcie może przerazić nas ich ilość.

Uwaga: Wykorzystując własne adnotacje do kwalifikowania metod mapujących koniecznie musimy pamiętać o załączeniu do niej adnotacji @Qualifier z pakietu MapStruct.

Metody typu Callback

@AfterMapping

Czasami bywa tak, że po przeprowadzeniu mapowania potrzebujemy jeszcze wykonać jakąś operację na mapowanym obiekcie. Przykładowo musimy uzupełnić pola w zależności od przekazanych warunków lub pobrać dodatkowe dane. Załóżmy, że mamy taką sytuację w której klasa Agreement posiada obiekt anex typu Agreement zaś nasze DTO posiada tylko id anexu. Po zmapowaniu obiektu chcemy dodatkowo dociągnąć anex za pomocą AgreementService. Tutaj pomóc nam może metoda opatrzona adnotacją @AfterMapping. Jest to metoda typu callback, która w tym przypadku wykona się zawsze pod koniec mapowania typów przekazanych do jej parametrów. W poniższym przypadku metoda afterAgreementDtoTOAgreementMapping wykona się zawsze pod koniec mapowania AgreementDTO –> Agreement. Stronę mapowania określa adnotacja @MappingTarget, bez niej procesor adnotacji nie uwzględni tej metody w implementacji mapperów i zostanie ona pominięta.

Uwaga: Abyśmy mogli wstrzyknąć bean springowy do implementacji mappera musimy zamienić interface na klasę abstrakcyjną i skorzystać z @Autowired. Zamiana interfejsu na klasę abstrakcyjną powinna przebiec bezkonfliktowo.

import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.springframework.beans.factory.annotation.Autowired;

@Mapper(uses = {AttachmentMapper.class}, componentModel = "spring")
public abstract class AgreementMapper {

    @Autowired
    protected AgreementService agreementService;

    @Mapping(target = "name", source = "agreementName")
    @Mapping(target = "product.id", source = "productId")
    @Mapping(target = "type", source = "agreementType")
    @Mapping(target = "attachments", source = "attachmentsDTO")
    @Mapping(ignore = true, target = "anex")
    abstract Agreement mapToAgreement(AgreementDTO agreementDTO);

    @AfterMapping
    void afterAgreementDtoTOAgreementMapping(AgreementDTO agreementDTO, @MappingTarget Agreement agreement) {
        if (agreementDTO.getAnexId() != null) {
            agreement.setAnex(agreementService.findById(agreementDTO.getAnexId()));
        }
    }
}
public class AgreementMapperImpl extends AgreementMapper {

    @Autowired
    private AttachmentMapper attachmentMapper;

    @Override
    Agreement mapToAgreement(AgreementDTO agreementDTO) {
        if ( agreementDTO == null ) {
            return null;
        }

        Agreement agreement = new Agreement();

        agreement.setProduct( agreementDTOToProduct( agreementDTO ) );
        agreement.setName( agreementDTO.getAgreementName() );
        agreement.setAttachments( attachmentDTOListToAttachmentSet( agreementDTO.getAttachmentsDTO() ) );
        if ( agreementDTO.getAgreementType() != null ) {
            agreement.setType( Enum.valueOf( AgreementStatus.class, agreementDTO.getAgreementType() ) );
        }
        agreement.setId( agreementDTO.getId() );
        agreement.setConclusionDate( agreementDTO.getConclusionDate() );

        afterAgreementDtoTOAgreementMapping( agreementDTO, agreement );

        return agreement;
    }
   ...
...
}

@BeforeMapping

Gdy już zapoznaliśmy się z działaniem @AfterMapping łatwo się domyślić, że @BeforeMapping będzie podobną metodą. Różnica jest taka, że metoda ta zostanie wykonana przed mapowaniem typów przekazanych jako argumenty. Tak jak w poprzednim przykładzie tak i tu niezbędna jest obecność adnotacji @MappingTarget która wskazuje kierunek mapowania oraz jest konieczna do wygenerowania ciała metody. BeforeMapping możemy wykorzystać np. do wykonania flush na encji ( jeśli jest taka potrzeba ) w celu upewnienia się, że obiekt został zapisany w bazie zanim zmapujemy go na DTO.

Uwaga: Do metody możemy przekazać tylko jeden argument opatrzony @MappingTarget. dodatkowo możemy dodać drugi argument tak jak w przypadku @AfterMappign, który potraktowany zostanie jako źródło.

@PersistenceContext
EntityManager entityManager;
 
@BeforeMapping     
protected void flushEntity(Agreement Agreement, @MappingTarget AgreementDTO agreementDTO) {
     entityManager.flush();
} 

@After/BeforeMapping tylko dla wybranych konfiguracji

Powyższe rozwiązanie ma według mnie jedną drobną aczkolwiek znaczącą wadę. Mianowicie, gdy posiadamy więcej niż jedno mapowanie AgreementDTO –> Agreement metody @BeforeMapping i @AfterMapping zostaną dodane do każdego z nich. Może to nie być do końca pożądane przez nas zachowanie. Sam spotkałem się z takim problemem i trudno było znaleźć jakieś rozwiązanie. I choć znalazłem wyjście z sytuacji to nie działa ono tak jak powinno. W dokumentacji znajdziemy dosłownie kilka zdań na ten temat:


All before/after-mapping methods that can be applied to a mapping method will be used. Mapping method selection based on qualifiers can be used to further control which methods may be chosen and which not. For that, the qualifier annotation needs to be applied to the before/after-method and referenced in BeanMapping#qualifiedBy or IterableMapping#qualifiedBy. Żródło

Z powyższego fragmentu możemy wywnioskować, że da się to obsłużyć wykorzystując adnotację @BeanMapping/IterableMapping (w zależności, czy jest to pojedyńczy obiekt czy kolekcja) oraz jej atrybuty qualifiedBy / qualifiedByName. Atrybuty te poznaliśmy w sekcjach ‘kwalifikowanie metody mapującej za pomocą nazwy’ i ‘kwalifikowanie metody mapującej za pomocą własnej Adnotacji’.

Teoretycznie powinniśmy być w stanie ręcznie w mapowanej metodzie wskazać, które metody before/after powinny być do niej załączone. W praktyce próbowałem to zrobić na różne sposoby, ale uzyskałem tylko połowiczny sukces. Wydaje mi się, że jest to błąd w implementacji, który zgłoszę do Twórców na githubie. Jeśli zostanie w przyszłości poprawiony to zaktualizuje tego posta z uwzględnieniem poprawek. Na chwilę obecną możemy sterować tym do jakiej metody mają być załączone metody before/after jednak są one nie rozłączne. Oznacza to że albo metoda mapująca skorzysta z obydwu albo z żadnej przykład powinien to wyjaśnić.

Uwaga: W tym przypadku raczej powinniśmy omijać dziedziczenie konfiguracji. Ponieważ gdy korzystamy z dziedziczenia konfiguracji, która zawiera zakwalifikowany before/after odziedziczymy także @BeanMapping#qualifiedBy, a tego chcemy uniknąć.

import org.mapstruct.AfterMapping;
import org.mapstruct.BeanMapping;
import org.mapstruct.BeforeMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.springframework.beans.factory.annotation.Autowired;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Mapper(uses = {AttachmentMapper.class,ProductMapper.class}, componentModel = "spring")
public abstract class AgreementMapper {

    @PersistenceContext
    EntityManager entityManager;

    @Autowired
    protected AgreementService agreementService;

    @Mapping(target = "name", source = "agreementName")
    @Mapping(target = "productDTO", source = "product", qualifiedBy = ProductWithoutDescription.class)
    @Mapping(target = "type", source = "agreementType")
    @Mapping(target = "attachments", source = "attachmentsDTO")
    @Mapping(ignore = true, target = "anex")
    @BeanMapping(qualifiedBy = {IncludeBeforeMapping.class})
    abstract Agreement mapToAgreement(AgreementDTO agreementDTO);


    @Mapping(ignore = true, target = "anex")
    abstract Agreement mapToAgreementWithoutAnex(AgreementDTO agreementDTO);

    @IncludeAfterMapping
    @AfterMapping
    void afterAgreementDtoTOAgreementMapping(AgreementDTO agreementDTO, @MappingTarget Agreement agreement) {
        if (agreementDTO.getAnexId() != null) {
            agreement.setAnex(agreementService.findById(agreementDTO.getAnexId()));
        }
    }

    @IncludeBeforeMapping
    @BeforeMapping
    void beforeflushAgreement(AgreementDTO agreementDTO, @MappingTarget Agreement agreement) {
        //some body
    }
}
@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2019-02-28T20:44:51+0100",
    comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)"
)
@Component
public class AgreementMapperImpl extends AgreementMapper {

    @Autowired
    private AttachmentMapper attachmentMapper;
    @Autowired
    private ProductMapper productMapper;

    @Override
    Agreement mapToAgreement(AgreementDTO agreementDTO) {
        if ( agreementDTO == null ) {
            return null;
        }

        Agreement agreement = new Agreement();

        beforeflushAgreement( agreementDTO, agreement );

        agreement.setName( agreementDTO.getAgreementName() );
        agreement.setProduct( productMapper.mapToProductWithoutDescription( agreementDTO.getProductDTO() ) );
        agreement.setAttachments( attachmentDTOListToAttachmentSet( agreementDTO.getAttachmentsDTO() ) );
        if ( agreementDTO.getAgreementType() != null ) {
            agreement.setType( Enum.valueOf( AgreementStatus.class, agreementDTO.getAgreementType() ) );
        }
        agreement.setId( agreementDTO.getId() );
        agreement.setConclusionDate( agreementDTO.getConclusionDate() );

        afterAgreementDtoTOAgreementMapping( agreementDTO, agreement );

        return agreement;
    }

    @Override
    Agreement mapToAgreementWithoutAnex(AgreementDTO agreementDTO) {
        if ( agreementDTO == null ) {
            return null;
        }

        Agreement agreement = new Agreement();

        agreement.setId( agreementDTO.getId() );
        agreement.setConclusionDate( agreementDTO.getConclusionDate() );

        return agreement;
    }
  ...
 ...
}
@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface IncludeAfterMapping {
}

@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface IncludeBeforeMapping {
}

Jak możemy zauważyć w powyższej implementacji w podświetlonych linijkach dodaliśmy adnotację @IncludeBeforeMapping oraz @IncludeAfterMapping nad metody after/before mapping. Zabieg ten pozwolił na wyłączenie automatycznego dołączania tychże metod do mapperów AgreementDTO agreementDTO –> Agreement agreement. Od tej chwili we wszystkie mapowaniach z AgreementDTO na Agreement musimy dołączyć ręcznie do metody poprzez @BeanMapping#qualifiedBy. Co ciekawe niezależnie co podamy w qualifiedBy czy to będzie IncludeBeforeMapping.class czy IncludeAfterMapping.class lub jakakolwiek inna adnotacja to w ciele metody zostaną załączone obydwie metody zarówno after jak i before mapping.

Tak jak pisałem wcześniej zakładam, że jest to bug który zostanie przeze mnie zgłoszony.
Proponuję jednak pobawić się powyższym przykładem i sprawdzić wynik implementacji na własnej skórze. Plus na pewno jest taki że możemy ograniczyć automatyczne dołączanie callback method do wybranych mapowań. Niestety na chwilę obecną jest to możliwe tylko w tandemie.

Pozostałe opcje Mapowania

Ta sekcja zawiera zbiór opcji mapowań rzadziej przeze mnie używanych aczkolwiek czasami bardzo pomocnych. Nie będę się tutaj rozpisywał na temat ich działania, ponieważ w większości przypadków można łatwo domyślić się ich działania z załączonego kodu źródłowego. Jeśli jednak będą jakieś pytania chętnie odpowiem na nie w komentarzach pod postem.

Wartości domyślne i stałe


Mapstruct daje nam mozliwość korzystania z wartości domyślnych oraz stałych podczas mapowaniu pól np.:

@Mapper(componentModel = "spring")
public interface SourceTargetMapper {

    @Mapping(target = "stringProperty", source = "stringProp", defaultValue = "undefined")
    @Mapping(target = "longProperty", source = "longProp", defaultValue = "1L")
    @Mapping(target = "stringConstant", constant = "Constant Value")
    @Mapping(target = "integerConstant", constant = "14")
    Target mapToSource(Source source);
}
@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2019-02-28T22:21:26+0100",
    comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)"
)
@Component
public class SourceTargetMapperImpl implements SourceTargetMapper {

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

        Target target = new Target();

        if ( source.getLongProp() != null ) {
            target.setLongProperty( source.getLongProp() );
        }
        else {
            target.setLongProperty( (long) 1L );
        }
        if ( source.getStringProp() != null ) {
            target.setStringProperty( source.getStringProp() );
        }
        else {
            target.setStringProperty( "undefined" );
        }

        target.setIntegerConstant( 14 );
        target.setStringConstant( "Constant Value" );

        return target;
    }
}

Jak można zauważyć wartość domyślna zostaje ustawiona tylko wtedy gdy wartość w source okaże się pusta. Inaczej to wygląda w przypadku stałych: atrybutu constant nie możemy łączyć z source. Wartość z tego pola ustawiana jest bezwarunkowo w zmiennej docelowej którą wskazuje atrybut target.

Format danych

Mamy także możliwość kontrolowania formatu daty oraz liczb za pomocą odpowiednich atrybutów adnotacji @Mapping

@Mapper(componentModel = "spring")
public interface SourceTargetMapper {

    @Mapping(target = "date", source = "stringProp", dateFormat = "dd-MM-yyyy")
    @Mapping(target = "stringProp", source = "intProp", numberFormat = "$#.00")
    Target mapToSource(Source source);

    @IterableMapping(numberFormat = "$#.00")
    List<String> prices(List<Integer> prices);

    @IterableMapping(dateFormat = "dd.MM.yyyy")
    List<String> stringListToDateList(List<LocalDate> dates);

    @MapMapping(valueDateFormat = "dd.MM.yyyy", keyNumberFormat = "$#.00")
    Map<String, String> longDateMapToStringStringMap(Map<Long, LocalDate> source);

}
@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2019-02-28T23:02:58+0100",
    comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)"
)
@Component
public class SourceTargetMapperImpl implements SourceTargetMapper {

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

        Target target = new Target();

        if ( source.getStringProp() != null ) {
            target.setDate( LocalDate.parse( source.getStringProp(), DateTimeFormatter.ofPattern( "dd-MM-yyyy" ) ) );
        }
        target.setStringProp( new DecimalFormat( "$#.00" ).format( source.getIntProp() ) );

        return target;
    }

    @Override
    public List<String> prices(List<Integer> prices) {
        if ( prices == null ) {
            return null;
        }

        List<String> list = new ArrayList<String>( prices.size() );
        for ( Integer integer : prices ) {
            list.add( new DecimalFormat( "$#.00" ).format( integer ) );
        }

        return list;
    }

    @Override
    public List<String> stringListToDateList(List<LocalDate> dates) {
        if ( dates == null ) {
            return null;
        }

        List<String> list = new ArrayList<String>( dates.size() );
        for ( LocalDate localDate : dates ) {
            list.add( DateTimeFormatter.ofPattern( "dd.MM.yyyy" ).format( localDate ) );
        }

        return list;
    }

    @Override
    public Map<String, String> longDateMapToStringStringMap(Map<Long, LocalDate> source) {
        if ( source == null ) {
            return null;
        }

        Map<String, String> map = new HashMap<String, String>( Math.max( (int) ( source.size() / .75f ) + 1, 16 ) );

        for ( java.util.Map.Entry<Long, LocalDate> entry : source.entrySet() ) {
            String key = new DecimalFormat( "$#.00" ).format( entry.getKey() );
            String value = DateTimeFormatter.ofPattern( "dd.MM.yyyy" ).format( entry.getValue() );
            map.put( key, value );
        }

        return map;
    }
}

Wyrażenia i wyrażenia domyślne

Co ciekawe mamy także możliwość wstrzykiwania kodu javy bezpośrednio z poziomu adnotacji @Mapper, który zostanie dodany podczas mapowania pola określonego w atrybucie target. Jednak moim zdaniem jeśli to jest coś bardziej skomplikowanego to lepszym sposobem będzie ręczne napisanie metody opatrzonej @Named/własną adnotacją i zakwalifikowanie jej do mapowania docelowego pola za pomocą qualifiedBy/qualifiedByName lub @After/BeforeMapping jeśli potrzebujemy mapować kilka wartości źródłowych na jedną docelową tak jak to widać w przypadku linni 8.

@Mapper(imports = UUID.class,componentModel = "spring")
public interface SourceTargetMapper {

    @Mapping(target = "id", source = "sourceId", defaultExpression = "java( UUID.randomUUID().toString() )")
    Target sourceToTarget(Source s);


    @Mapping(target = "dictionary", expression = "java( new SomeDictionary( s.getKey(), s.getValue() ) )")
    Target sourceToTargetWithCustomObject(Source s);
}

Jest jeszcze jedna kwestia o której należy wspomnieć. Niektórzy może zauważyli, że w adnotacji @Mapper(imports = UUID.class,componentModel = “spring”) pojawił się dodatkowy atrybut imports = UUID.class jak sama nazwa wskazuje importuje on do implementacji wygenerowanego mappera klasę podaną jako wartość. Atrybut przyjmuje tablicę klas więc jest możliwość załączenia więcej niż jednej pozycji. W naszym przypadku było to konieczne ponieważ użyliśmy tej klasy we wstrzykiwanym kodzie Javy.

Podsumowanie

MapStruct to bardzo zaawansowane narzędzie, które w odpowiednich rękach może znacznie przyspieszyć pracę programisty. Jak można było zauważyć w tej części dzięki wysokiej możliwości konfiguracji z łatwością możemy dostosować generowane mappery do naszych potrzeb. Niewykluczone jednak, że w niektórych przypadkach nie obędzie się bez dodania ręcznej metody mapującej. Zachęcam do ściągnięcia przerabianego kodu z github (tutaj) i pobawienie się konfiguracją w domowym zaciszu.