MapStruct custom list mapping for bi-directional Hibernate association
Asked Answered
W

2

8

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:

  1. 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. [Update: This can be simplified as suggested by Ben's answer.]

  1. 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.

  1. Add a method setBiDirectionalReviews in Book 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?

Weise answered 23/4, 2022 at 21:10 Comment(0)
O
12

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));
    }
Odessa answered 23/4, 2022 at 23:21 Comment(1)
Thanks! Solution 2 (CollectionMappingStrategy) is exactly what I was looking for.Weise
A
0

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

Edit: Eugene's answer is better in my opinion.

Airline answered 23/4, 2022 at 22:21 Comment(2)
Thanks for the hint. Even though the CollectionMappingStrategy was the answer I was looking for, the secondary method is great idea for reducing manual mapping. But this should work with interface as well, or am I missing something?Weise
should also work with interfaces, but then you are also exposing the other methods. With abstract class you are only exposing the primary mapping method, therefor decreasing the chance of using the wrong mapping method.Airline

© 2022 - 2024 — McMap. All rights reserved.