1

I have an issue with a many-to-many relation in Spring Boot. Code is as follows:


public class Task {

  @Id
  @GeneratedValue
  private Long id;

  @ManyToMany(cascade = {PERSIST, MERGE}, fetch = EAGER)
  @JoinTable(
      name = "task_tag",
      joinColumns = {@JoinColumn(name = "task_id", referencedColumnName = "id")},
      inverseJoinColumns = {@JoinColumn(name = "tag_id", referencedColumnName = "id")}
  )
  @Builder.Default
  private Set<Tag> tags = new HashSet<>();

  public void addTags(Collection<Tag> tags) {
    tags.forEach(this::addTag);
  }

  public void addTag(Tag tag) {
    this.tags.add(tag);
    tag.getTasks().add(this);
  }

  public void removeTag(Tag tag) {
    tags.remove(tag);
    tag.getTasks().remove(this);
  }

  public void removeTags() {
    for (Iterator<Tag> iterator = this.tags.iterator(); iterator.hasNext(); ) {
      Tag tag = iterator.next();
      tag.getTasks().remove(this);
      iterator.remove();
    }
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Task)) return false;
    return id != null && id.equals(((Task) o).getId());
  }

  @Override
  public int hashCode() {
    return id.intVal();
  }
}

and

public class Tag {

  @Id
  @GeneratedValue
  private Long id;

  @NotNull
  @Column(unique = true)
  private String name;

  @ManyToMany(cascade = {PERSIST, MERGE}, mappedBy = "tags", fetch = EAGER)
  @Builder.Default
  private final Set<Task> tasks = new HashSet<>();

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Tag tag = (Tag) o;
    return Objects.equals(name, tag.name);
  }

  @Override
  public int hashCode() {
    return id.intVal();
  }

}

Of course, I have the task_tag table where, after inserting a tag in a task and saving that task, an entry appears. However, when I delete a tag (or clear them), the entries do not get deleted from the join table. This is the test:

@Test
  void entityIntegration() {
    Task task = taskRepo.save(...);

    Tag tag1 = Tag.builder().name(randomString()).build();
    Tag tag2 = Tag.builder().name(randomString()).build();
    Tag tag3 = Tag.builder().name(randomString()).build();
    Tag tag4 = Tag.builder().name(randomString()).build();
    final List<Tag> allTags = Arrays.asList(tag1, tag2, tag3, tag4);
    tagRepo.saveAll(allTags);

    task.addTag(tag1);
    taskRepo.save(task);
    final Long task1Id = task.getId();
    assertTrue(tag1.getTasks().stream().map(Task::getId).collect(Collectors.toList()).contains(task1Id));

    task.clearTags();
    task = taskRepo.save(task);
    tag1 = tagRepo.save(tag1);
    assertTrue(task.getTags().isEmpty());
    assertTrue(tag1.getTasks().isEmpty());

    task.addTags(allTags);
    task = taskRepo.save(task); // FAILS, duplicate key ...

  }

I delete tag1 but when I try to add it back to the task, I get enter image description here

The task_tag table does have a composite index formed on those two (and only) columns.

What am I doing wrong? I followed each and every suggestion and advice - using set instead of lists, having helper methods, cleaning up etc... I can't find the bug.

Thank you!

Alexandr
  • 708
  • 9
  • 29
  • 1
    `getClass().hashCode()` (i.e. a constant) is the worst you could possibly return from an object's `hashCode` implementation – crizzis Apr 12 '21 at 16:52
  • Irrelevant, but I changed it. – Alexandr Apr 13 '21 at 08:53
  • After the `task.addTags(allTags)` line, if you print `task.getTags()` in a foreach loop, how many are there? Did you get the exact same problem when using `List`s? Also, I'd check if it works when you remove `cascade = {PERSIST, MERGE}` from `Tag.tasks` mapping? – crizzis Apr 13 '21 at 16:04
  • Why do you need the bidirectional mapping? I would suggest a tag shouldn't know about the tasks. Anyway, are you sure that your `clearTags` or `removeTags` implementation works correctly i.e. it removes the task correctly from the tasks set? Using the id in equals/hashCode can be problematic if the object is added to a hash based collection like in your case. Try reloading the data completely after persisting (i.e. after the id is generated and set) – Christian Beikov Apr 14 '21 at 12:14
  • Yeap, those helper methods do work correctly – Alexandr Apr 15 '21 at 15:14
  • Maybe it is transaction/session boundaries of the test itself so the changes are not synced to db, try flushing the persistence context before `task.addTags(allTags)`. – Dragan Bozanovic Apr 18 '21 at 10:11
  • Just to avoid confusion, which is the correct (latest) implementation that you're using to clear the tags.. clearTags or removeTags.. can you update above question accordingly so it's synched up? – Atmas Apr 21 '21 at 03:34

2 Answers2

0

The biggest thing that sticks out to me is that your Tag's equals and hash-code aren't matched with each other.

Your "equals" drives equality based on the object's name being the same, which makes logical sense to the mantra of "A tag is a name". But the hash-code drives based on the "id" being equivalent and doesn't use the name at all.

Forgetting JPA/Hibernate for a moment, just plain old Collections themselves get very unpredictable when these two are out of synch.

You can read more about that here and specifically why a hash-code that doesn't match equality would end up hashing to the wrong bucket and resulting in confusing keeping it all straight in HashSets: Why do I need to override the equals and hashCode methods in Java?

There are many ways to put them back in synch (using libraries like Lombok and code-generation tools in your IDE come to mind), but instead of prescribing one, I will simply point to this web-resource that, conveniently, created a Tag with the exact same concept for his example, so I suspect you can just use this exact same pattern yourself.

https://vladmihalcea.com/the-best-way-to-use-the-manytomany-annotation-with-jpa-and-hibernate/

Here's another helpful SO thread I found that talks about relationships and identity/equals/hashCode as it impacts JPA: The JPA hashCode() / equals() dilemma

Atmas
  • 2,389
  • 5
  • 13
-2

Kindly add DELETE keyword to the cascade property of many to many annotation . And i believe ur annotation for task property of Tag class should be changed as below .

You can give this below mapping a try


public class Task {

  @Id
  @GeneratedValue
  private Long id;

  @ManyToMany(cascade = {PERSIST, MERGE,DELETE}, fetch = EAGER)
  @JoinTable(
      name = "task_tag",
      joinColumns = {@JoinColumn(name = "task_id", referencedColumnName = "id")},
      inverseJoinColumns = {@JoinColumn(name = "tag_id", referencedColumnName = "id")}
  )
  @Builder.Default
  private Set<Tag> tags = new HashSet<>();

  public void addTags(Collection<Tag> tags) {
    tags.forEach(this::addTag);
  }

  public void addTag(Tag tag) {
    this.tags.add(tag);
    tag.getTasks().add(this);
  }

  public void removeTag(Tag tag) {
    tags.remove(tag);
    tag.getTasks().remove(this);
  }

  public void removeTags() {
    for (Iterator<Tag> iterator = this.tags.iterator(); iterator.hasNext(); ) {
      Tag tag = iterator.next();
      tag.getTasks().remove(this);
      iterator.remove();
    }
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Task)) return false;
    return id != null && id.equals(((Task) o).getId());
  }

  @Override
  public int hashCode() {
    return id.intVal();
  }
}
public class Tag {

  @Id
  @GeneratedValue
  private Long id;

  @NotNull
  @Column(unique = true)
  private String name;

  @ManyToMany(cascade = {PERSIST, MERGE,DELETE}, mappedBy = "tags", fetch = EAGER)
@JoinTable(
      name = "task_tag",
      joinColumns = {@JoinColumn(name = "tag_id", referencedColumnName = "id")},
      inverseJoinColumns = {@JoinColumn(name = "task_id", referencedColumnName = "id")}
  )
  @Builder.Default
  private final Set<Task> tasks = new HashSet<>();

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Tag tag = (Tag) o;
    return Objects.equals(name, tag.name);
  }

  @Override
  public int hashCode() {
    return id.intVal();
  }

}

  • It doesn't fix the problem and definitely adding REMOVE (instead of DELETE) there is not something that I want. I don't want my projects removed when a tag associated with them is removed. – Alexandr Apr 15 '21 at 15:13