How to manage the relationships of @Any and @ManyToAny entities when deleting?

I’m hoping to get some pointers on how to manage the relationships of entities using Hibernate’s @Any and @ManyToAny, specifically when removing related records. I am currently unable to find guidance in the Hibernate docs and have had limited success elsewhere. Simply put, when I delete “child” records, I don’t know how to get Hibernate to manage (i.e., clean up after) the owners of those records.

Examples

Let’s start with a project on Github with entities and tests quite similar to those found in the documentation referenced above. The two test classes in this project create “child” entities, create a “parent” entity for those children, and then attempt to delete the child entity.

@Any

A PropertyHolder can hold any Property`:

@Entity
@Table(name = "property_holder")
public class PropertyHolder {

    @Id
    private Long id;

    @Any
    @AnyDiscriminator(DiscriminatorType.STRING)
    @AnyDiscriminatorValue(discriminator = "S", entity = StringProperty.class)
    @AnyDiscriminatorValue(discriminator = "I", entity = IntegerProperty.class)
    @AnyKeyJavaClass(Long.class)
    @Column(name = "property_type")
    @JoinColumn(name = "property_id")
    private Property<?> property;

    ...
}

Let’s create a StringProperty and a PropertyHolder related to it in PropertyHolderTests (abridged here):

StringProperty nameProperty = new StringProperty();
nameProperty.setId(1L);
nameProperty.setName("name");
nameProperty.setValue("John Doe");
save(nameProperty);

PropertyHolder namePropertyHolder = new PropertyHolder();
namePropertyHolder.setId(1L);
save(namePropertyHolder);

namePropertyHolder.setProperty(nameProperty);
update(namePropertyHolder);

...

void save(Object object) {
    EntityManager entityManager = getEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();
    transaction.begin();
    entityManager.persist(object);
    transaction.commit();
}

void update(Object object) {
    EntityManager entityManager = getEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();
    transaction.begin();
    entityManager.clear();
    entityManager.merge(object);
    transaction.commit();
}

Now, delete the property:

delete(StringProperty.class, 1L);

PropertyHolder propertyHolder = retrieve(PropertyHolder.class, 1L);
assertThat(propertyHolder.getProperty()).isNull(); // FAILS

...

<T> void delete(Class<T> entityType, Object id) {
    EntityManager entityManager = getEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();
    transaction.begin();
    T object = entityManager.find(entityType, id);
    System.out.printf("Removing %s %s%n", entityType.getSimpleName(), id);
    entityManager.remove(object);
    System.out.printf("Committing %s %s%n", entityType.getSimpleName(), id);
    transaction.commit();
}

<T> T retrieve(Class<T> entityType, Object id) {
    return getEntityManager().find(entityType, id);
}

:white_check_mark: What I expect to happen is StringProperty #1 is deleted and PropertyHolder #1 to still be viable without its property.

:warning: What happens is StringProperty #1 is deleted, i.e., its row is removed from the string_property table. But the EntityManager returns null for PropertyHolder #1 because its row in the property_holder table still references the deleted property: HHH015013: Returning null (as required by JPA spec) rather than throwing EntityNotFoundException, as the entity (type=com.example.hibernatepolymorph.entity.PropertyHolder, id=1) does not exist

@ManyToAny

A PropertyRepository can be associated with any number of Properties:

@Entity
@Table(name = "property_repository")
public class PropertyRepository {

    @Id
    private Long id;

    @ManyToAny
    @AnyDiscriminator(DiscriminatorType.STRING)
    @Column(name = "property_type")
    @AnyKeyJavaClass(Long.class)
    @AnyDiscriminatorValue(discriminator = "S", entity = StringProperty.class)
    @AnyDiscriminatorValue(discriminator = "I", entity = IntegerProperty.class)
    @Cascade(CascadeType.ALL)
    @JoinTable(name = "repository_properties",
            joinColumns = @JoinColumn(name = "repository_id"),
            inverseJoinColumns = @JoinColumn(name = "property_id")
    )
    private List<Property<?>> properties = new ArrayList<>();

    ...
}

Let’s create a StringProperty and an IntegerProperty followed by a PropertyRepository related to them both in PropertyRepositoryTests`:

IntegerProperty ageProperty = new IntegerProperty();
ageProperty.setId(2L);
ageProperty.setName("age");
ageProperty.setValue(23);
save(ageProperty);

StringProperty nameProperty = new StringProperty();
nameProperty.setId(2L);
nameProperty.setName("name");
nameProperty.setValue("John Doe");
save(nameProperty);

PropertyRepository propertyRepository = new PropertyRepository();
propertyRepository.setId(1L);
save(propertyRepository);

propertyRepository.getProperties().add(ageProperty);
propertyRepository.getProperties().add(nameProperty);
update(propertyRepository);

Now, delete one of the properties:

delete(IntegerProperty.class, 2L);

PropertyRepository propertyRepository = retrieve(PropertyRepository.class, 1L); // FAILS
assertThat(propertyRepository.getProperties()).hasSize(1);

:white_check_mark: What I expect to happen is IntegerProperty #2 is deleted and PropertyRepository #1 to only be related to StringProperty #2.

:warning: What happens is IntegerProperty #2 is deleted, i.e., its row is removed from the integer_property. But the row in repository_properties which relates PropertyRepository #1 with IntegerProperty #2 is not deleted. So, the EntityManager throws an exception when trying to retrieve PropertyRepository #1: jakarta.persistence.EntityNotFoundException: Unable to find com.example.hibernatepolymorph.entity.IntegerProperty with id 2

Solutions?

As I said earlier, I’ve been unable to find much guidance on how to handle these situations. The Hibernate docs show how to create these relationships and entities, but I didn’t find anything specific to @Any or @ManyToAny. So, what is the intended or recommended method for having Hibernate cleanup these relationships after or during a delete? The only other source I’ve found is an article from 2019 on Medium which suggests using a PreDeleteEventListener and an Integrator. Is this still a viable solution?

The data in the join table is managed by the plural attribute PropertyHolder#properties, so if you want to remove an element from that join table, you will have to load the respective property holders and remove the elements from that collection.

In your post, I assume you meant “managed by the plural attribute PropertyRepository#properties. And, yes, I could remove the IntegerProperty from the PropertyRepository this way:

PropertyRepository propertyRepository = retrieve(PropertyRepository.class, 1L);
propertyRepository.getProperties().removeIf(o -> o instanceof IntegerProperty && o.getId() == 2L);
update(propertyRepository);

PropertyRepository propertyRepository2 = retrieve(PropertyRepository.class, 1L);
assertThat(propertyRepository2.getProperties()).satisfiesExactlyInAnyOrder(
        name -> {
            assertThat(name).isInstanceOf(StringProperty.class);
            assertThat(name.getId()).isEqualTo(2L);
            assertThat(name.getName()).isEqualTo("name");
            assertThat(name.getValue()).isEqualTo("John Doe");
        }
);

However, my goal here is to delete the IntegerProperty itself. Let’s say we get to a point where we have multiple (maybe dozens or hundreds of) PropertyRepository objects related to the same IntegerProperty:

IntegerProperty ageProperty = retrieve(IntegerProperty.class, 2L);

StringProperty nameProperty = new StringProperty();
nameProperty.setId(12L);
nameProperty.setName("name");
nameProperty.setValue("Jane Doe");
save(nameProperty);

PropertyRepository anotherPropertyRepository = new PropertyRepository();
anotherPropertyRepository.setId(2L);
save(anotherPropertyRepository);

anotherPropertyRepository.getProperties().add(ageProperty);
anotherPropertyRepository.getProperties().add(nameProperty);
update(anotherPropertyRepository);

assertThat(propertyRepository.getId()).isEqualTo(2L);

Later on, I want to delete that IntegerProperty:

delete(IntegerProperty.class, 2L);

PropertyRepository propertyRepository1 = retrieve(PropertyRepository.class, 1L); // FAILS
assertThat(propertyRepository1.getProperties()).satisfiesExactlyInAnyOrder(
        name -> {
            assertThat(name).isInstanceOf(StringProperty.class);
            assertThat(name.getId()).isEqualTo(2L);
            assertThat(name.getName()).isEqualTo("name");
            assertThat(name.getValue()).isEqualTo("John Doe");
        }
);

At this point, all of the PropertyRepository objects related to the deleted IntegerProperty are “broken”.

Are you saying I’ll have to have my service code find all of the PropertyRepository objects which contain IntegerProperty #2, loop through them removing IntegerProperty #2 from their properties and updating them, and then finally deleting IntegerProperty #2? And I’ll have to do the same for all of the Property*Holder*#property related to it too? And then wrap the whole thing up in a @Transaction so the updates aren’t partially applied? (FYI, I’m trying to implement this all within a Spring Boot application.)

I guess I was hoping there was some kind of mechanism to “cascade” the deletion of an IntegerProperty to its related records. Can the PreDeleteEventListener be used for that? Or to at least trigger it? I haven’t found much besides the one Medium article on how to implement one of those.

Yes, you understood this correctly. There is no “automation” or cascading possible, not even with other *-to-many associations. Hibernate ORM simply has no way to remove elements from join tables if the target row is deleted.
Usually, you would be using foreign key cascade deletes to make this more automatic, but since you probably don’t have foreign keys, that’s not an option.
We might add support for collections in HQL DML at some point so that you can formulate the delete on the join table as query directly instead of loading + removing from the collection, but we haven’t started with that yet.

:tada: For anyone else who has run into this issue, I’ve updated my Github project with a solution:

I was able to piece together a solution for Hibernate to execute some code in the event an entity is deleted. This was thanks to the Medium article mentioned above, by Josh Harkema, and a post on Vlad Mihalcea’s website. Start by creating a PreDeleteEventListener whose onPreDelete() method will handle the PreDeleteEvent Hibernate issues before deleting an entity.

public class ApplicationPreDeleteEventListener implements PreDeleteEventListener {

    private final PropertyEventHandler propertyEventHandler = new PropertyEventHandler();


    @Override
    public boolean onPreDelete(PreDeleteEvent event) {
        Object entity = event.getEntity();
        boolean veto = entity == null;

        if (! veto && entity instanceof Property<?> property) {
            veto = propertyEventHandler.preDelete(property, event.getSession());
        }

        return veto;
    }
}

The onPreDelete() method returns a boolean that tells Hibernate whether to veto the original delete that triggered the event.
In this implementation, the PropertyEventHandler uses the provided EventSource to remove relationships from the given Property prior to its deletion:

public class PropertyEventHandler {

    public boolean preDelete(Property<?> property, EventSource eventSource) {
        return removeFromPropertyHolders(property, eventSource) ||
                removeFromPropertyRepositories(property, eventSource);
    }

    protected boolean removeFromPropertyHolders(Property<?> property, EventSource eventSource) {
        eventSource.getSession()
                   .createNativeMutationQuery("""
                           UPDATE property_holder
                              SET property_type = NULL
                                , property_id = NULL
                            WHERE property_id = :property_id
                           """)
                   .setParameter("property_id", property.getId())
                   .setHibernateFlushMode(FlushMode.MANUAL)
                   .executeUpdate();
        return false;
    }

    protected boolean removeFromPropertyRepositories(Property<?> property, EventSource eventSource) {
        eventSource.getSession()
                   .createNativeMutationQuery("""
                           DELETE FROM repository_properties
                            WHERE property_id = :property_id
                              AND property_type = :property_type
                           """)
                   .setParameter("property_id", property.getId())
                   .setParameter("property_type", property.getDiscriminator())
                   .setHibernateFlushMode(FlushMode.MANUAL)
                   .executeUpdate();
        return false;
    }
}

In order to enable this mechanism in the project, the ApplicationPreDeleteEventListener needs to be “integrated” into Hibernate.

:information_source: There may be other ways of “integrating” event listeners into Hibernate, but this one worked for me.

Create an implementation of Integrator:

public class ApplicationIntegrator implements Integrator {

    @Override
    public void integrate(@UnknownKeyFor @NonNull @Initialized Metadata metadata,
                          @UnknownKeyFor @NonNull @Initialized BootstrapContext bootstrapContext,
                          @UnknownKeyFor @NonNull @Initialized SessionFactoryImplementor sessionFactory) {

        final EventListenerRegistry eventListenerRegistry =
                sessionFactory.getServiceRegistry().getService(EventListenerRegistry.class);

        eventListenerRegistry.appendListeners(EventType.PRE_DELETE, ApplicationPreDeleteEventListener.class);
    }

    @Override
    public void disintegrate(@UnknownKeyFor @NonNull @Initialized SessionFactoryImplementor sessionFactoryImplementor,
                             @UnknownKeyFor @NonNull @Initialized SessionFactoryServiceRegistry sessionFactoryServiceRegistry) {
        // We HAVE to override this...
    }
}

:information_source: The @UnknownKeyFor, @NonNull and @Initialized annotations on the integrate() method are from the org.checkerframework:checker-qual package, which is necessary to create an Integrator.

Next, create an implementation of IntegratorProvider:

public class ApplicationIntegratorProvider implements IntegratorProvider {

    @Override
    public List<Integrator> getIntegrators() {
        return List.of(new ApplicationIntegrator());
    }
}

Finally, tell Hibernate to use the ApplicationIntegratorProvider. If using Hibernate alone, append this to the project’s persistence.xml:

<persistence ...>
    <persistence-unit ...>
        ...
        <properties>
            ...
            <property name="hibernate.integrator_provider" value="com.example.hibernatepolymorph.config.ApplicationIntegratorProvider"/>
        </properties>
    </persistence-unit>
    ...
</persistence>

If using Spring Boot, append this to the application.properties:

spring.jpa.properties.hiberrnate.integrator_provider=package.name.to.ApplicationIntegratorProvider

Now, all of the project’s tests perform as expected. :tada:

1 Like