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.
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.
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:
Guarantees the join table is already synchronized (flush()).
Keeps the logic in your domain/service layer instead of relying on low-level callbacks.
Works with any JPA provider (EclipseLink, Hibernate, etc.).
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.