javaspringhibernate

Hibernate failing to insert parent of ManyToMany relationship when it contains a child not currently in the join table


I'm trying to insert a new Item entity which has a Set of Tag children. However, if one of the Tags does not exist in the join table already, the transaction fails with this error:

org.hibernate.engine.jdbc.spi.SqlExceptionHelper.logExceptions SQL Error: 0, SQLState: 23503
org.hibernate.engine.jdbc.spi.SqlExceptionHelper.logExceptions ERROR: insert or update on table "item_tag" violates foreign key constraint "fk_tag_name"
  Detail: Key (tag_name)=(new tag) is not present in table "tag".

I was under the impression that making the parent the owning side & performing operations on it would cascade them to the child objects, but it seems as if Hibernate is attempting to save data to the join table before the child is added. Checking if each tag on the item exists & inserting it if it doesn't seems clunky & a bit counter-intuitive, so what is the proper approach to this?

This is the relevant part of the Item class:

public class Item {
...
    @ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH, CascadeType.DETACH})
    @JoinTable(
            name = "item_tag",
            joinColumns = @JoinColumn(name = "item_id"),
            inverseJoinColumns = @JoinColumn(name = "tag_name")
    )
    private Set<Tag> tags = new HashSet<>();

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Item item = (Item) o;
        return id == item.id && Objects.equals(owner, item.owner) && Objects.equals(ownerName, item.ownerName) && Objects.equals(name, item.name) && Objects.equals(description, item.description) && Objects.equals(date, item.date) && Objects.equals(images, item.images) && Objects.equals(tags, item.tags) && Objects.equals(location, item.location);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, owner, ownerName, name, description, date, images, tags, location);
    }
...
}

and the Tag class:

public class Tag {
...
    @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JsonIgnore
    private Set<Item> items = 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(description, tag.description) && Objects.equals(firstUsed, tag.firstUsed) && Objects.equals(items, tag.items);
    }

    @Override
    public int hashCode() {
        return Objects.hash(description, firstUsed, items);
    }
...
}

Here is the structure of the respective tables:

                                     Table "public.item"
   Column    |           Type           | Collation | Nullable |           Default
-------------+--------------------------+-----------+----------+------------------------------
 id          | bigint                   |           | not null | generated always as identity
 name        | character varying        |           | not null |
 description | character varying        |           |          |
 date        | timestamp with time zone |           | not null | now()
 location_id | bigint                   |           |          |
 owner_id    | bigint                   |           |          |
Indexes:
    "items_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "fk_location_id" FOREIGN KEY (location_id) REFERENCES location(id)
    "fk_owner_id" FOREIGN KEY (owner_id) REFERENCES users(id)
Referenced by:
    TABLE "image" CONSTRAINT "fk_item_id" FOREIGN KEY (item_id) REFERENCES item(id)
    TABLE "item_tag" CONSTRAINT "fk_item_id" FOREIGN KEY (item_id) REFERENCES item(id)
                           Table "public.tag"
   Column    |           Type           | Collation | Nullable | Default
-------------+--------------------------+-----------+----------+---------
 description | character varying        |           | not null |
 first_used  | timestamp with time zone |           |          | now()
Indexes:
    "tag_pkey" PRIMARY KEY, btree (description)
Referenced by:
    TABLE "item_tag" CONSTRAINT "fk_tag_name" FOREIGN KEY (tag_name) REFERENCES tag(description)

and the join table:

                    Table "public.item_tag"
  Column  |       Type        | Collation | Nullable | Default
----------+-------------------+-----------+----------+---------
 item_id  | bigint            |           | not null |
 tag_name | character varying |           | not null |
Indexes:
    "item_tag_pkey" PRIMARY KEY, btree (item_id, tag_name)
Foreign-key constraints:
    "fk_item_id" FOREIGN KEY (item_id) REFERENCES item(id)
    "fk_tag_name" FOREIGN KEY (tag_name) REFERENCES tag(description)

EDIT: I've just changed the CascadeType to CascadeType.ALL in the Item entity's @ManyToMany annotation. Is there a reason why it would work like this but not when all CascadeTypes save CascadeType.REMOVE are passed?


Solution

  • I've just changed the CascadeType to CascadeType.ALL in the Item entity's @ManyToMany annotation. Is there a reason why it would work like this but not when all CascadeTypes save CascadeType.REMOVE are passed?

    Yes and no. Hibernate has additional cascade types not specified by JPA, and its own, extended version of the the CascadeType enum. It apparently accepts javax.persistence.CascadeType.ALL as inclusive of all its cascade types, including the extended ones, but "all [javax.persistence.]CascadeTypes save CascadeType.REMOVE" omits not just REMOVE but also Hibernate's extended cascade types, including DELETE, LOCK, REPLICATE, and SAVE_UPDATE. Of those, SAVE_UPDATE is the only one that seems likely to be relevant to your problem, and indeed, you reported in comments that adding that resolved the issue.

    I account it a Hibernate bug that it requires you to express a non-standard cascade type in order to get persist operations to cascade across your relationship. CascadeType.PERSIST should accomplish that by itself. It's ok for Hibernate to provide for cascading operations outside the scope of JPA's cascade types, and it would be ok for it to provide finer-grained cascade types, but if it claims to conform to the JPA specs (which as far as I know, it does) then it needs to behave as JPA specifies.

    I guess it's possible that you were using (only) org.hibernate.annotations.CascadeType all along, instead of javax.persistence.CascadeType. It might then be argued that Hibernate has license for its PERSIST cascade type to behave differently than JPA's standard one, but even then there is at least a documentation flaw, because Hibernate does not appear to document any difference between the effects of its PERSIST and of JPA's.

    I note also that this might be impacted by the version of Hibernate you are using. In Hibernate 6.5, CascadeType.SAVE_UPDATE is deprecated. The only non-deprecated extended cascade type relevant to modern Hibernate is apparently LOCK.