What is the best way to perform MapStruct mapping from a Data Transfer Object (DTO) to a Hibernate entity with a bi-directional one-to-many association?
Assume we have a BookDto
with multiple reviews of type ReviewDto
linked to it:
public class BookDto {
private List<ReviewDto> reviews;
// getter and setters...
}
The corresponding Hibernate entity Book
has a one-to-many association to Review
:
@Entity
public class Book {
@OneToMany(mappedBy = "book", orphanRemoval = true, cascade = CascadeType.ALL)
private List<Review> reviews = new ArrayList<>();
public void addReview(Review review) {
this.reviews.add(review);
review.setBook(this);
}
//...
}
@Entity
public class Review {
@ManyToOne(fetch = FetchType.LAZY)
private Book book;
public void setBook(Book book) {
this.book = book;
}
//...
}
Note that the book's addReview
method sets the association bi-directionally by also calling review.setBook(this)
as recommended by Hibernate experts (e.g., 'Hibernate Tips: How to map a bi-directional many-to-one association' by Thorben Janssen or 'How to synchronize bidirectional entity associations with JPA and Hibernate' by Vlad Mihalcea) in order to ensure Domain Model relationship consistency.
Now, we want a MapStruct mapper that automatically links the review back to the book. There are multiple options that I have found so far, each of which has some drawbacks:
- Custom mapping method:
@Mapper
public interface BookMapper {
default Book mapBookDtoToBook(BookDto bookDto) {
//...
for (ReviewDto reviewDto : bookDto.getReviews()) {
book.addReview(mapReviewDtoToReview(reviewDto));
}
//...
}
//...
}
This gets cumbersome if the book has many other fields to map.
- Make the relationship bi-directional in an
@AfterMapping
method:
@Mapper
public interface BookMapper {
Book mapBookDtoToBook(Book book); // Implementation generated by MapStruct
@AfterMapping
void linkReviewsToBook(@MappingTarget Book book) {
for (Review review : book.getReviews()) {
review.setBook(book);
}
}
//...
}
This approach allows MapStruct to generate all other field mappings; but by decoupling the auto-generated setReviews
from the setBook
operation in the after-mapping, we lose cohesion.
- Add a method
setBiDirectionalReviews
inBook
and instruct MapStruct to use it as target:
@Entity
public class Book {
//...
public void setBiDirectionalReviews(List<Review> reviews) {
this.reviews = reviews;
for (Review review : this.reviews) {
review.setBook(this);
}
}
}
@Mapper
public class BookMapper {
@Mapping(source = "reviews", target = "biDirectionalReviews")
Book mapBookDtoToBook(Book book);
}
Now we have re-established cohesion, but (1) we might still need the additional method addReview
if we wanted to modify the existing reviews somewhere else, and (2) it feels somewhat hacky to abuse MapStruct's accessor naming strategy by pretending there were a field named "biDirectionalReviews".
Anyway, this is the best approach that I could find so far.
Is there a better solution to mapping bi-directional associations in MapStruct?
CodePudding user response:
Instead of an interface
you can do the first option with an abstract class
. That way you can expose the primary method while still using mapstruct for all mappings except the Book
's reviews
. This also removes the drawback of needing to manually map the other fields, since you can use a secondary method for it.
@Mapper
public abstract class BookMapper {
public Book map(BookDto dto) {
Book book = mapBook( dto );
for ( ReviewDto reviewDto : dto.getReviews() ) {
Review review = mapReview( reviewDto );
book.addReview( review );
}
return book;
}
// mapstruct annotations
@Mapping(target = "reviews", ignore=true) // if needed
abstract Book mapBook(BookDto dto);
// mapstruct annotations
@Mapping(target = "book", ignore=true) // if needed
abstract Review mapReview(ReviewDto reviewDto);
}
CodePudding user response:
Solution 1, @Context
This problem can be resolved via @Context.
Mapstruct contributors provide example how to use @Context
in parent/child relations in JPA.
Example:
@Mapper
public interface JpaMapper {
JpaMapper MAPPER = Mappers.getMapper( JpaMapper.class );
Book toEntity(BookDTO s, @Context JpaContext ctx);
@Mapping(target = "book", ignore = true)
Review toEntity(ReviewDTO s, @Context JpaContext ctx);
}
public class JpaContext {
private Book bookEntity;
@BeforeMapping
public void setEntity(@MappingTarget Book parentEntity) {
this.bookEntity = parentEntity;
// you could do stuff with the EntityManager here
}
@AfterMapping
public void establishRelation(@MappingTarget Review childEntity) {
childEntity.setBook(bookEntity);
// you could do stuff with the EntityManager here
}
}
Usage:
Book book = JpaMapper.MAPPER.toEntity(bookDTO, new JpaContext());
Generated code:
public class JpaMapperImpl implements JpaMapper {
@Override
public Book toEntity(BookDTO s, JpaContext ctx) {
if ( s == null ) {
return null;
}
Book book = new Book();
ctx.setEntity( book );
book.setId( s.getId() );
book.setName( s.getName() );
book.setReviews( reviewDTOListToReviewList( s.getReviews(), ctx ) );
return book;
}
@Override
public Review toEntity(ReviewDTO s, JpaContext ctx) {
if ( s == null ) {
return null;
}
Review review = new Review();
review.setId( s.getId() );
review.setName( s.getName() );
ctx.establishRelation( review );
return review;
}
protected List<Review> reviewDTOListToReviewList(List<ReviewDTO> list, JpaContext ctx) {
if ( list == null ) {
return null;
}
List<Review> list1 = new ArrayList<Review>( list.size() );
for ( ReviewDTO reviewDTO : list ) {
list1.add( toEntity( reviewDTO, ctx ) );
}
return list1;
}
}
Solution 2, Collection mapping strategies
You can specify CollectionMappingStrategy.ADDER_PREFERRED strategy for collection mapping. It means that Mapstruct will use addReview(Review review)
method for adding Review
objects to collection.
I think this is the right solution.
@Mapper(collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED)
public interface JpaMapper {
JpaMapper MAPPER = Mappers.getMapper( JpaMapper.class );
Book toEntity(BookDTO s);
@Mapping(target = "book", ignore = true)
Review toEntity(ReviewDTO s);
}
Generated code:
public class JpaMapperImpl implements JpaMapper {
@Override
public Book toEntity(BookDTO s) {
if ( s == null ) {
return null;
}
Book book = new Book();
book.setId( s.getId() );
book.setName( s.getName() );
if ( s.getReviews() != null ) {
for ( ReviewDTO review : s.getReviews() ) {
book.addReview( toEntity( review ) );
}
}
return book;
}
@Override
public Review toEntity(ReviewDTO s) {
if ( s == null ) {
return null;
}
Review review = new Review();
review.setId( s.getId() );
review.setName( s.getName() );
return review;
}
}
Unit test:
@Test
public void test() {
BookDTO bookDTO = new BookDTO();
bookDTO.setId(1L);
bookDTO.setName("Book 1");
ReviewDTO reviewDTO1 = new ReviewDTO();
reviewDTO1.setId(1L);
reviewDTO1.setName("Review 1");
ReviewDTO reviewDTO2 = new ReviewDTO();
reviewDTO2.setId(2L);
reviewDTO2.setName("Review 2");
List<ReviewDTO> reviewDTOS = Arrays.asList(reviewDTO1, reviewDTO2);
bookDTO.setReviews(reviewDTOS);
Book book = JpaMapper.MAPPER.toEntity(bookDTO, new JpaContext());
//Book book = JpaMapper.MAPPER.toEntity(bookDTO);
Assert.assertNotNull(book);
Assert.assertEquals(book.getId(), book.getId());
Assert.assertEquals(book.getName(), bookDTO.getName());
Assert.assertEquals(book.getReviews().size(), bookDTO.getReviews().size());
Assert.assertEquals(book.getReviews().get(0).getId(), bookDTO.getReviews().get(0).getId());
Assert.assertEquals(book.getReviews().get(1).getId(), bookDTO.getReviews().get(1).getId());
book.getReviews().forEach(review -> Assert.assertEquals(review.getBook(), book));
}