Does cascade="all-delete-orphan" have any meaning in a Hibernate unidirectional many-to-many association with a join table?

matt b picture matt b · Sep 30, 2011 · Viewed 31.5k times · Source

I have two objects which form a parent-child relationship which have a many-to-many relationship. Following the recommendations in the Hibernate reference manual, I have mapped this using a join table:

<class name="Conference" table="conferences">
    ...
    <set name="speakers" table="conference_speakers" cascade="all">
        <key column="conference_id"/>
        <many-to-many class="Speaker" column="speaker_id"/>
    </set>
</class>

<class name="Speaker" table="speakers">
    <id name="id" column="id">
        <generator class="native"/>
    </id>
    <property name="firstName"/>
    <property name="lastName"/>
</class>

My wish is that a single Speaker can be associated with many different Conferences, but also that any Speaker which is no longer referenced by any Conference is removed from the speakers table (as a Speaker with no associated conferences doesn't have much meaning in my project).

However, I've found that if I use cascade="all-delete-orphan", then if a Speaker which is associated with multiple Conferences is removed from just one of them, Hibernate attempts to delete the Speaker instance itself.

Below is a unit test which shows this behavior:

@Test
public void testRemoveSharedSpeaker() {

    int initialCount = countRowsInTable("speakers");

    Conference c1 = new Conference("c1");
    Conference c2 = new Conference("c2");

    Speaker s = new Speaker("John", "Doe");

    c1.getSpeakers().add(s);
    c2.getSpeakers().add(s);

    conferenceDao.saveOrUpdate(c1);
    conferenceDao.saveOrUpdate(c2);
    flushHibernate();

    assertEquals(initialCount + 1, countRowsInTable("speakers"));
    assertEquals(2, countRowsInTable("conference_speakers"));

    // the remove:
    c1 = conferenceDao.get(c1.getId());
    c1.getSpeakers().remove(s);

    flushHibernate();

    assertEquals("count should stay the same", initialCount + 1, countRowsInTable("speakers"));
    assertEquals(1, countRowsInTable("conference_speakers"));

    c1 = conferenceDao.get(c1.getId());
    c2 = conferenceDao.get(c2.getId());

    assertEquals(0, c1.getSpeakers().size());
    assertEquals(1, c2.getSpeakers().size());
}

An error is thrown when s's removal from c1.speakers is processed, because Hibernate is deleting both the row in the join table and the speakers table row as well:

DEBUG org.hibernate.SQL - delete from conference_speakers where conference_id=? and speaker_id=?
DEBUG org.hibernate.SQL - delete from speakers where id=?

If I change cascade="all-delete-orphan" to just cascade="all", then this test works as expected, although it leads to the undesired behavior where I will end up with orphaned rows in my speakers table.

This leads me to wonder - is it even possible for Hibernate to know when to delete orphaned objects from the child-side of the relationship, but only when the child is not referenced by any other parents (whether or not those parents are in the current Session)? Perhaps I am misusing cascade="all-delete-orphan"?

I get the same exact behavior if I use JPA annotations instead of XML mapping such as:

@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(name = "conference_speakers",
        joinColumns = @JoinColumn(name = "conference_id"),
        inverseJoinColumns = @JoinColumn(name = "speaker_id"))
@org.hibernate.annotations.Cascade(org.hibernate.annotations.CascadeType.DELETE_ORPHAN)
private Set<Speaker> speakers = new HashSet<Speaker>();

This is with Hibernate 3.6.7.Final, by the way.

Answer

ChssPly76 picture ChssPly76 · Sep 30, 2011

DELETE_ORPHAN cascade mode is not defined for many-to-many relationship - only for one-to-many (the latter sports a "orphanRemoval=true|false" attribute within JPA standard @OneToMany annotation, so you don't have to resort to proprietary Hibernate annotation).

The reason for this is exactly as you've described - there's no way for Hibernate to figure out whether "orphaned" end of the many-to-many relationship is truly orphaned without running a query against the database which is both counter-intuitive and can (potentially) have serious performance implications.

Hibernate behavior you've described is therefore correct (well, "as documented"); though in a perfect world it would have alerted you to the fact that DELETE_ORPHAN is illegal on many-to-many during 2nd pass mappings compilation.

I can't think of a good way of achieving what you want to do, to be honest. The easiest (but database-specific) way would likely be to define a trigger on deletion from conference_speakers that would check whether this speaker is "truly" orphaned and delete it from speakers if so. The database-independent option is to do the same thing manually in DAO or listener.

Update: Here's an excerpt from Hibernate docs (Chapter 11.11, right after gray Note on CascadeType.ALL), highlights are mine:

A special cascade style, delete-orphan, applies only to one-to-many associations, and indicates that the delete() operation should be applied to any child object that is removed from the association.

Further down:

It does not usually make sense to enable cascade on a many-to-one or many-to-many association. In fact the @ManyToOne and @ManyToMany don't even offer a orphanRemoval attribute. Cascading is often useful for one-to-one and one-to-many associations.