javajpa

How to implement post remove behavior on a JoinTable using EclipseLink jpa?


Having unidirectional ManyToMany mapping in an entity called A to entity called B on field called files:

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = JOIN_TABLE_NAME)
private List<B> files = new ArrayList<>();

, which creates a join table with ids of each entity as composite primary key.

Automatically, when an item is removed from files list and A entity is saved, a row is also removed from join table.

I would like to implement this behavior:

Whenever a row is removed from this join table, check if there are no more rows with B-id as removed row B-id, if so, remove rows from B table matching that B-id.

There might be several ways to implement this. Some of which i have tought, but are unsatisfactory or do not work:

Create join table as separate entity and add a post remove method. However this method is not called:

@Entity
@PrimaryKeyJoinColumns(
    @PrimaryKeyJoinColumn(name = "a_id"),
    @PrimaryKeyJoinColumn(name = "b_id")
)
@Table(name = JOIN_TABLE_NAME)
public class ABjoinTable {
    
    @EmbeddedId
    private ABId abid;
    
    @Embeddable
    public static final class ABId {
    
        @Column(name = "a_id);
        public long aid;
    
        @Column(name = "b_id);
        public long bid;
    }
    
    @PostRemove
    public void removeDanglingFiles() {
            // not called
    }
}

Create a remove trigger on join table, however B entity has PostRemove method already implemented, which in this solution will not be called (trigger is on db level).

Use PostUpdate method on A entity and remove dangling rows manually, however rows are not yet removed from join table in post update context:

@PostLoad
public void createFilesSnapshot() {
    filesSnapshot = new ArrayList<>(this.files);
}

@PostUpdate
public void removeDanglingFiles() {
    // if there are no more references to other Bids, delete that B
    val removed = filesSnapshot.stream()
            .filter(b -> !this.files.contains(b))
            .map(PersistentObject::getId)
            .collect(Collectors.toSet());
    try (val em = JPAutil.getEntityManager()) {
        val tx = em.getTransaction();
        tx.begin();
        for (val rId : removed) {
            val q = em.createQuery("SELECT a" +
                    " FROM A a JOIN a.files b" +
                    " WHERE b.id = :bId", B.class);
            q.setParameter("bId", rId);
            val result = q.getResultList();
            if (result.size() == 0) { // at this point in post load, join table row is not yet removed, so size is >= 1
                em.remove(...);
            }
        }
        tx.commit();
    } catch (final Exception e) {
    }
}

Using EclipseLink jpa provider and MySql db.


Solution

  • You can solve this by doing the cleanup after the transaction is flushed, i.e. using a @TransactionalEventListener or a SessionEventListener (if using EclipseLink-specific APIs).
    Alternatively, you can hook into your service layer logic, which is the most straightforward and JPA-compliant solution.

    Option 1) Handle it in your service layer (recommended)

    Instead of relying on entity callbacks, handle the cleanup explicitly after saving A:

    @Transactional
    public void saveA(A a) {
        // Snapshot before save (to know what was removed)
        List<B> oldFiles = repository.findById(a.getId())
                                     .map(A::getFiles)
                                     .orElse(Collections.emptyList());
    
        repository.save(a); // triggers join table updates
    
        // Flush to ensure join table rows are removed
        entityManager.flush();
    
        // Check which Bs were removed
        Set<Long> removedIds = oldFiles.stream()
                .filter(b -> !a.getFiles().contains(b))
                .map(B::getId)
                .collect(Collectors.toSet());
    
        // Delete Bs no longer referenced
        for (Long bId : removedIds) {
            Long count = entityManager.createQuery(
                "SELECT COUNT(a) FROM A a JOIN a.files b WHERE b.id = :bId", Long.class)
                .setParameter("bId", bId)
                .getSingleResult();
    
            if (count == 0) {
                B b = entityManager.find(B.class, bId);
                entityManager.remove(b);
            }
        }
    }
    

    This approach:


    Option 2) Use EclipseLink session events (provider-specific)

    EclipseLink exposes a lower-level SessionEventAdapter you can register to catch post-commit or post-delete events at the session level:

    public class CleanupListener extends SessionEventAdapter {
        @Override
        public void postCommitUnitOfWork(SessionEvent event) {
            EntityManager em = (EntityManager) event.getResult();
            // Perform the same orphan check logic here
        }
    }
    

    You’d register this via persistence.xml or SessionCustomizer.
    This gives you “after commit” behavior without polluting your entity model.