Search code examples
javaspringjpamany-to-manyhibernate-mapping

How to save multiple entity in one action related in Many to Many Relationship [Spring Boot 2, JPA, Hibernate, PostgreSQL]


i'm writing here to have some hind about the solution that make , in short the problem that I've faced is: I have two entity in bidirectional many to many relationship, for instance I've the following Post and Tag entities:

@Data
@Entity
@Table(name = "posts")
public class Post {


    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    /*...*/

    @ManyToMany( cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH} )
    @JoinTable(name = "post_tag", 
            joinColumns = @JoinColumn(name = "post_id", referencedColumnName = "id"), 
            inverseJoinColumns = @JoinColumn(name = "tag_id", referencedColumnName = "id"))
    @JsonIgnoreProperties("posts")
    private Set<Tag> tags = new HashSet<>();

}

@Data
@Entity
@Table(name = "tags")
public class Tag {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;    

    @NaturalId
    private String text;

    @ManyToMany(mappedBy = "tags")//, cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH})
    @JsonIgnoreProperties("tags")
    private Set<Post> posts = new HashSet<>();

}

My problem is that in a HTTP POST Action I get the data for the post and a Collection of tags related to it and I have to save all with the condition to not duplicate the tags entity if the "text" is already present in the database. Assuming we have a Map with the given data, the code is as follow below:

Post post = new Post();
String heading = (String) payload.get("heading");
String content = (String) payload.get("content");
post.setHeading(heading);
post.setContent(content);
Set<Tag> toSaveTags = new HashSet<Tag>();
List list = (List) payload.get("tags");
for (Object o : list) {
    Map map = (Map) o;
    String text = (String) map.get("text");
    Tag tag = new Tag();
    tag.setText(text);
    post.getTags().add(tag);
    tag.getPosts().add(post);
    log.info("post has {}# tag", post.getTags().size());
    toSaveTags.add(tag);
};
//method to save it all
postRepository.saveWithTags(post, toSaveTags);

My solution was to design a Repository class with the method shown above, as follow:

@Repository
public class PostTagsRepositoryImpl implements PostTagsRepository {

    @Autowired
    private EntityManagerFactory emf;

    @Override
    public Post saveWithTags(Post post, Collection<Tag> tags) {
        EntityManager entityManager = emf.createEntityManager();
        post.getTags().clear();
        for (Tag tag : tags) {

            tag.getPosts().clear();
            Tag searchedTag = null;
            try {
                searchedTag = entityManager.createQuery(
                        "select t "
                        + "from Tag t "
                        + "join fetch t.posts "
                        + "where t.text = :text", Tag.class)
                        .setParameter("text", tag.getText())
                        .getSingleResult();
            } catch (NoResultException e) {/* DO NOTHING */}
            if (searchedTag == null) {
                post.getTags().add(tag);
                tag.getPosts().add(post);
            } else {
                entityManager.getTransaction().begin();
                entityManager.detach(searchedTag);

                post.getTags().add(searchedTag);
                searchedTag.getPosts().add(post);
                entityManager.merge(searchedTag);
                entityManager.getTransaction().commit();
            }
        }

        entityManager.getTransaction().begin();
        entityManager.merge(post);
        entityManager.getTransaction().commit();
        return post;
    }

}

My questions are : Could I implement it better? maybe in a single query/transaction? Could you give me some tips?


Solution

  • Some points:

    • you link both entities and then clear relations in the repository. As you don't have invariants between them first linkage is useless.

    • maybe in a single query/transaction?

    Single query is not really possible but single transaction is indeed what you need to achieve to avoid inconsistency problems.

    • Unfortunately cascading merge won't work with naturalid so you have to produce this behavior yourself. So for each tag verify if it exists:
    Session session = entityManager.unwrap(Session.class);
    
    Tag t= session.bySimpleNaturalId(Tag.class).load(“some txt”);
    

    Depending on the result you have to load into the Post object the existing tag one (already on db and recover via bySimpleNaturalId) or the new one. Then cascading merge on Post will do the rest.

    • You always create new entity manager on each call to your repository. You should overcome this by injection the shared entitymanager directly.
    @Autowired
    Private EntityManager em;
    

    It is thread safe.