From d7b9f6688ab6454b50197a8c91f5cab4e6348230 Mon Sep 17 00:00:00 2001 From: Andrey Glushkov Date: Wed, 1 Jul 2026 11:25:27 +0300 Subject: [PATCH 1/2] Invalidate inverse association caches on owning-side FK change --- .../server/core/PersistRequestBean.java | 20 ++++- .../deploy/BeanDescriptorCacheHelp.java | 79 ++++++++++++++++--- .../server/deploy/BeanPropertyAssocOne.java | 77 +++++++++++++++++- .../server/transaction/TableModState.java | 2 +- .../server/transaction/TableModStateTest.java | 3 +- 5 files changed, 168 insertions(+), 13 deletions(-) diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java index 6e7886370..236510929 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java @@ -56,6 +56,11 @@ public final class PersistRequestBean extends PersistRequest implements BeanP private Object idValue; private boolean statelessUpdate; private boolean notifyCache; + /** + * Snapshot of origValues taken before intercept.setLoaded() clears them. + * Used so that cache update code can access old FK values after setLoaded(). + */ + private Object[] capturedOrigValues; /** * Flag used to detect when only many properties where updated via a cascade. Used to ensure * appropriate caches are updated in that case. @@ -710,7 +715,11 @@ public final class PersistRequestBean extends PersistRequest implements BeanP * Return the original / old value for the given property. */ public Object origValue(BeanProperty prop) { - return intercept.origValue(prop.propertyIndex()); + int idx = prop.propertyIndex(); + if (capturedOrigValues != null && idx < capturedOrigValues.length) { + return capturedOrigValues[idx]; + } + return intercept.origValue(idx); } @Override @@ -867,6 +876,15 @@ public final class PersistRequestBean extends PersistRequest implements BeanP if (type == Type.UPDATE) { // get the dirty properties for notify cache & orphanRemoval of vanilla collection detection dirtyProperties = intercept.dirtyProperties(); + // snapshot orig values before setLoaded() clears them - needed by cache FK-eviction logic + if (dirtyProperties != null && notifyCache) { + capturedOrigValues = new Object[dirtyProperties.length]; + for (int i = 0; i < dirtyProperties.length; i++) { + if (dirtyProperties[i]) { + capturedOrigValues[i] = intercept.origValue(i); + } + } + } } if (isChangeLog) { changeLog(); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorCacheHelp.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorCacheHelp.java index f76a927d4..85fb8de56 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorCacheHelp.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorCacheHelp.java @@ -63,6 +63,12 @@ final class BeanDescriptorCacheHelp { * Set to true if delete changes need to notify cache. */ private boolean cacheNotifyOnDelete; + /** + * Set to true if this bean type has owning OneToOne properties pointing to cached targets. + * When true, all persist events must evict the target bean caches regardless of whether + * this bean type itself has a bean cache (bypasses the cacheRegion.isEnabled() gate). + */ + private boolean cacheNotifyOneToOneOwner; BeanDescriptorCacheHelp(BeanDescriptor desc, SpiCacheManager cacheManager, CacheOptions cacheOptions, boolean cacheSharableBeans, BeanPropertyAssocOne[] propertiesOneImported) { @@ -108,15 +114,25 @@ final class BeanDescriptorCacheHelp { void deriveNotifyFlags() { cacheNotifyOnAll = (invalidateQueryCache || beanCache != null || queryCache != null); cacheNotifyOnDelete = !cacheNotifyOnAll && isNotifyOnDeletes(); + cacheNotifyOneToOneOwner = hasOwningOneToOneWithCachedTarget(); if (log.isLoggable(DEBUG)) { - if (cacheNotifyOnAll || cacheNotifyOnDelete) { - String notifyMode = cacheNotifyOnAll ? "All" : "Delete"; + if (cacheNotifyOnAll || cacheNotifyOnDelete || cacheNotifyOneToOneOwner) { + String notifyMode = cacheNotifyOnAll ? "All" : (cacheNotifyOnDelete ? "Delete" : "OneToOneOwner"); log.log(DEBUG, "l2 caching on {0} - beanCaching:{1} queryCaching:{2} notifyMode:{3} ", desc.fullName(), isBeanCaching(), isQueryCaching(), notifyMode); } } } + private boolean hasOwningOneToOneWithCachedTarget() { + for (BeanPropertyAssocOne imported : propertiesOneImported) { + if (imported.isCacheNotifyOwningOneToOne()) { + return true; + } + } + return false; + } + /** * Return true if there is an imported bi-directional relationship to a bea * that does have bean caching enabled. @@ -137,6 +153,9 @@ final class BeanDescriptorCacheHelp { if (hasImmutableCaches()) { return true; } + if (cacheNotifyOneToOneOwner) { + return true; + } return cacheRegion.isEnabled() && (cacheNotifyOnAll || cacheNotifyOnDelete && (type == PersistRequest.Type.DELETE || type == PersistRequest.Type.DELETE_PERMANENT)); } @@ -756,7 +775,7 @@ final class BeanDescriptorCacheHelp { } void cacheUpdateQuery(boolean update, SpiTransaction transaction) { - if (invalidateQueryCache || cacheNotifyOnAll || (!update && cacheNotifyOnDelete)) { + if (invalidateQueryCache || cacheNotifyOnAll || cacheNotifyOneToOneOwner || (!update && cacheNotifyOnDelete)) { transaction.event().add(desc.baseTable(), false, update, !update); } } @@ -776,6 +795,7 @@ final class BeanDescriptorCacheHelp { changeSet.addBeanRemoveMany(desc, ids); } cacheDeleteImported(true, null, changeSet); + cacheClearImportedOneToOne(changeSet); } } @@ -794,6 +814,7 @@ final class BeanDescriptorCacheHelp { changeSet.addBeanRemove(desc, id); } cacheDeleteImported(true, deleteRequest.entityBean(), changeSet); + cacheDeleteImportedOneToOne(deleteRequest.entityBean(), changeSet); } } @@ -806,6 +827,7 @@ final class BeanDescriptorCacheHelp { } else { queryCacheClear(changeSet); cacheDeleteImported(false, insertRequest.entityBean(), changeSet); + cacheDeleteImportedOneToOne(insertRequest.entityBean(), changeSet); changeSet.addBeanInsert(desc.baseTable()); } } @@ -816,6 +838,42 @@ final class BeanDescriptorCacheHelp { } } + /** + * Evict the target bean cache for each owning OneToOne property on insert or delete. + */ + private void cacheDeleteImportedOneToOne(EntityBean entityBean, CacheChangeSet changeSet) { + for (BeanPropertyAssocOne imported : propertiesOneImported) { + imported.cacheDeleteOneToOneOwned(entityBean, changeSet); + } + } + + /** + * Clear the target bean cache for each owning OneToOne property on bulk delete-by-ids. + */ + private void cacheClearImportedOneToOne(CacheChangeSet changeSet) { + for (BeanPropertyAssocOne imported : propertiesOneImported) { + imported.cacheClearOneToOneOwned(changeSet); + } + } + + /** + * Evict inverse caches for any imported FK properties whose value changed in this update. + * Handles collection-ids caches (ManyToOne FK reassignment) and bean caches (owning OneToOne FK change). + */ + private void cacheUpdateImportedFKs(PersistRequestBean updateRequest, CacheChangeSet changeSet) { + boolean[] dirty = updateRequest.dirtyProperties(); + if (dirty == null) { + return; + } + EntityBean entityBean = updateRequest.entityBean(); + for (BeanPropertyAssocOne imported : propertiesOneImported) { + int propIdx = imported.propertyIndex(); + if (propIdx < dirty.length && dirty[propIdx]) { + imported.cacheUpdateFKchange(entityBean, updateRequest.origValue(imported), changeSet); + } + } + } + /** * Add appropriate changes to support update. */ @@ -828,6 +886,7 @@ final class BeanDescriptorCacheHelp { } else { queryCacheClear(changeSet); + cacheUpdateImportedFKs(updateRequest, changeSet); if (beanCache == null) { // query caching only return; @@ -858,15 +917,17 @@ final class BeanDescriptorCacheHelp { changeSet.addInvalidate(desc); return; } - if (noCaching) { + if (noCaching && !cacheNotifyOneToOneOwner) { return; } - changeSet.addClearQuery(desc); - // inserts don't invalidate the bean cache - if (tableIUD.isUpdateOrDelete()) { - changeSet.addClearBean(desc); + if (!noCaching) { + changeSet.addClearQuery(desc); + // inserts don't invalidate the bean cache + if (tableIUD.isUpdateOrDelete()) { + changeSet.addClearBean(desc); + } } - // any change invalidates the collection IDs cache + // any change invalidates the collection IDs cache and owning OneToOne target bean caches for (BeanPropertyAssocOne imported : propertiesOneImported) { imported.cacheClear(changeSet); } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocOne.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocOne.java index 816dd7841..ed2cc7cb9 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocOne.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocOne.java @@ -53,6 +53,7 @@ public class BeanPropertyAssocOne extends BeanPropertyAssoc implements STr private String deleteByParentIdInSql; private BeanPropertyAssocMany relationshipProperty; private boolean cacheNotifyRelationship; + private boolean cacheNotifyOwningOneToOne; /** * Create based on deploy information of an EmbeddedId. @@ -142,6 +143,7 @@ public class BeanPropertyAssocOne extends BeanPropertyAssoc implements STr */ void initialisePostTarget() { this.cacheNotifyRelationship = isCacheNotifyRelationship(); + this.cacheNotifyOwningOneToOne = oneToOne && !oneToOneExported && targetDescriptor.isBeanCaching(); } /** @@ -164,18 +166,31 @@ public class BeanPropertyAssocOne extends BeanPropertyAssoc implements STr } /** - * Clear the L2 relationship cache for this property. + * Return true if this owning OneToOne property requires target bean cache eviction on change. + */ + boolean isCacheNotifyOwningOneToOne() { + return cacheNotifyOwningOneToOne; + } + + /** + * Clear the L2 relationship cache for this property (used from beanCacheApplyInvalidate). */ void cacheClear() { if (cacheNotifyRelationship) { targetDescriptor.cacheManyPropClear(relationshipProperty.name()); } + if (cacheNotifyOwningOneToOne) { + targetDescriptor.clearBeanCache(); + } } void cacheClear(CacheChangeSet changeSet) { if (cacheNotifyRelationship) { changeSet.addManyClear(targetDescriptor, relationshipProperty.name()); } + if (cacheNotifyOwningOneToOne) { + changeSet.addClearBean(targetDescriptor); + } } /** @@ -198,6 +213,66 @@ public class BeanPropertyAssocOne extends BeanPropertyAssoc implements STr } } + /** + * Evict the target's bean cache entry when this owning OneToOne bean is inserted or deleted. + */ + void cacheDeleteOneToOneOwned(EntityBean bean, CacheChangeSet changeSet) { + if (cacheNotifyOwningOneToOne) { + Object assocBean = getValue(bean); + if (assocBean != null) { + Object targetId = targetDescriptor.id(assocBean); + if (targetId != null) { + changeSet.addBeanRemove(targetDescriptor, targetId); + } + } + } + } + + /** + * Clear the target's entire bean cache when owning OneToOne beans are deleted by IDs. + */ + void cacheClearOneToOneOwned(CacheChangeSet changeSet) { + if (cacheNotifyOwningOneToOne) { + changeSet.addClearBean(targetDescriptor); + } + } + + /** + * Evict the inverse caches for BOTH old and new targets when the FK changes on update. + * Handles collection-ids caches (ManyToOne) and bean caches (owning OneToOne). + */ + void cacheUpdateFKchange(EntityBean bean, Object origAssocBean, CacheChangeSet changeSet) { + Object newAssocBean = getValue(bean); + if (cacheNotifyRelationship) { + if (origAssocBean != null) { + Object oldId = targetDescriptor.id(origAssocBean); + if (oldId != null) { + changeSet.addManyRemove(targetDescriptor, relationshipProperty.name(), targetDescriptor.cacheKey(oldId)); + } + } + if (newAssocBean != null) { + Object newId = targetDescriptor.id(newAssocBean); + if (newId != null) { + changeSet.addManyRemove(targetDescriptor, relationshipProperty.name(), targetDescriptor.cacheKey(newId)); + } + } + } + if (cacheNotifyOwningOneToOne) { + if (origAssocBean != null) { + Object oldId = targetDescriptor.id(origAssocBean); + if (oldId != null) { + changeSet.addBeanRemove(targetDescriptor, oldId); + } + } + if (newAssocBean != null) { + Object newId = targetDescriptor.id(newAssocBean); + if (newId != null) { + changeSet.addBeanRemove(targetDescriptor, newId); + } + } + } + } + @Override Object naturalKeyVal(Map values) { EntityBean bean = (EntityBean) values.get(name); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TableModState.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TableModState.java index 6bc7bfe82..8d03c54f4 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TableModState.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TableModState.java @@ -48,7 +48,7 @@ public final class TableModState implements QueryCacheEntryValidate, ServerCache boolean isValid(Set tables, Instant sinceTime) { for (String tableName : tables) { final var modTime = tableModStamp.get(tableName); - if (modTime != null && !modTime.isBefore(sinceTime)) { + if (modTime != null && modTime.compareTo(sinceTime) > 0) { if (log.isLoggable(TRACE)) { log.log(TRACE, "Invalidate on table:{0}", tableName); } diff --git a/ebean-core/src/test/java/io/ebeaninternal/server/transaction/TableModStateTest.java b/ebean-core/src/test/java/io/ebeaninternal/server/transaction/TableModStateTest.java index 5ef264ab9..35f108649 100644 --- a/ebean-core/src/test/java/io/ebeaninternal/server/transaction/TableModStateTest.java +++ b/ebean-core/src/test/java/io/ebeaninternal/server/transaction/TableModStateTest.java @@ -15,8 +15,9 @@ class TableModStateTest { private final TableModState tableModState = new TableModState(); @Test - void isValid() { + void isValid() throws InterruptedException { Instant before = Instant.now(); + Thread.sleep(5); tableModState.touch(setOf("one", "two", "three")); From a59c70054bcd3d1f9d915afac3d870afce827625 Mon Sep 17 00:00:00 2001 From: Andrey Glushkov Date: Wed, 1 Jul 2026 11:48:28 +0300 Subject: [PATCH 2/2] Tests --- .../cache/TestInverseAssociationCache.java | 364 ++++++++++++++++++ .../model/cache/o2o/OCachedO2ODetail.java | 51 +++ .../cache/o2o/OCachedO2ODetailNoCached.java | 48 +++ .../model/cache/o2o/OCachedO2OOwner.java | 49 +++ .../o2o/OCachedO2OOwnerNoCachedDetail.java | 49 +++ 5 files changed, 561 insertions(+) create mode 100644 ebean-test/src/test/java/org/tests/cache/TestInverseAssociationCache.java create mode 100644 ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetail.java create mode 100644 ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetailNoCached.java create mode 100644 ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwner.java create mode 100644 ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwnerNoCachedDetail.java diff --git a/ebean-test/src/test/java/org/tests/cache/TestInverseAssociationCache.java b/ebean-test/src/test/java/org/tests/cache/TestInverseAssociationCache.java new file mode 100644 index 000000000..8ad688911 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/cache/TestInverseAssociationCache.java @@ -0,0 +1,364 @@ +package org.tests.cache; + +import io.ebean.CacheMode; +import io.ebean.DB; +import io.ebean.SqlUpdate; +import io.ebean.cache.ServerCache; +import io.ebean.xtest.BaseTestCase; +import io.ebeaninternal.server.cache.CachedManyIds; +import org.junit.jupiter.api.Test; +import org.tests.model.basic.OCachedBean; +import org.tests.model.basic.OCachedBeanChild; +import org.tests.model.cache.o2o.OCachedO2ODetail; +import org.tests.model.cache.o2o.OCachedO2ODetailNoCached; +import org.tests.model.cache.o2o.OCachedO2OOwner; +import org.tests.model.cache.o2o.OCachedO2OOwnerNoCachedDetail; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that inverse association L2 caches are evicted when the owning side changes, + * without evicting unrelated cache entries (precision), and that the fix is a no-op when + * the target entity has no bean cache. + *
    + *
  • collection-ids cache (parent) when a child's {@code @ManyToOne} FK is reassigned
  • + *
  • bean cache (detail) when an owning {@code @OneToOne} FK is inserted, updated, or deleted
  • + *
+ */ +class TestInverseAssociationCache extends BaseTestCase { + + // ---- collection-ids cache: ManyToOne FK reassignment (reparenting) ---- + + /** + * When a child's ManyToOne FK is updated to point to a different parent (reparenting), + * both the old and new parent's collection-ids caches must be evicted. + */ + @Test + void collectionIds_whenChildReparented_bothParentsEvicted() { + OCachedBean p1 = new OCachedBean(); + p1.setName("p1-reparent"); + OCachedBean p2 = new OCachedBean(); + p2.setName("p2-reparent"); + DB.save(p1); + DB.save(p2); + + OCachedBeanChild child = new OCachedBeanChild(); + child.setCachedBean(p1); + DB.save(child); + + // populate both parents' collection-ids caches + assertThat(DB.find(OCachedBean.class, p1.getId()).getChildren()).hasSize(1); + assertThat(DB.find(OCachedBean.class, p2.getId()).getChildren()).isEmpty(); + + // reparent the child from p1 to p2 (FK update) — ebean did not evict either cache before this fix + OCachedBeanChild childRef = DB.find(OCachedBeanChild.class, child.getId()); + childRef.setCachedBean(DB.reference(OCachedBean.class, p2.getId())); + DB.save(childRef); + awaitL2Cache(); + + assertThat(DB.find(OCachedBean.class, p1.getId()).getChildren()) + .as("old parent collection should be empty after reparent").isEmpty(); + assertThat(DB.find(OCachedBean.class, p2.getId()).getChildren()) + .as("new parent collection should contain the moved child").hasSize(1); + + // cleanup + DB.delete(childRef); + DB.delete(p1); + DB.delete(p2); + } + + /** + * Precision: reparenting a child only evicts the old and new parent's collection-ids entry; + * a third parent's collection-ids cache entry must remain untouched. + */ + @Test + void collectionIds_whenChildReparented_unrelatedParentCacheUnaffected() { + OCachedBean p1 = new OCachedBean(); + p1.setName("p1-prec"); + OCachedBean p2 = new OCachedBean(); + p2.setName("p2-prec"); + OCachedBean p3 = new OCachedBean(); + p3.setName("p3-prec"); + DB.save(p1); + DB.save(p2); + DB.save(p3); + + OCachedBeanChild childOfP1 = new OCachedBeanChild(); + childOfP1.setCachedBean(p1); + OCachedBeanChild childOfP3 = new OCachedBeanChild(); + childOfP3.setCachedBean(p3); + DB.save(childOfP1); + DB.save(childOfP3); + + // populate all three collection caches + assertThat(DB.find(OCachedBean.class, p1.getId()).getChildren()).hasSize(1); + assertThat(DB.find(OCachedBean.class, p2.getId()).getChildren()).isEmpty(); + assertThat(DB.find(OCachedBean.class, p3.getId()).getChildren()).hasSize(1); + + // verify p3's cache entry is actually present before the reparent + ServerCache collCache = DB.cacheManager().collectionIdsCache(OCachedBean.class, "children"); + CachedManyIds p3Before = (CachedManyIds) collCache.get(String.valueOf(p3.getId())); + assertThat(p3Before).as("p3 collection-ids cache populated before reparent").isNotNull(); + + // reparent childOfP1 from p1 to p2 (only p1 and p2 should be affected) + OCachedBeanChild childRef = DB.find(OCachedBeanChild.class, childOfP1.getId()); + childRef.setCachedBean(DB.reference(OCachedBean.class, p2.getId())); + DB.save(childRef); + awaitL2Cache(); + + // p3's collection-ids cache entry must still be present (only p1 and p2 were evicted) + CachedManyIds p3After = (CachedManyIds) collCache.get(String.valueOf(p3.getId())); + assertThat(p3After).as("p3 collection-ids cache must not be evicted by unrelated reparent").isNotNull(); + + // cleanup + DB.delete(childRef); + DB.delete(childOfP3); + DB.delete(p1); + DB.delete(p2); + DB.delete(p3); + } + + // ---- exported OneToOne: bean cache eviction ---- + + /** + * When a new owning OneToOne bean is inserted pointing at a detail, the detail's + * bean cache must be evicted so the next read reflects the newly established link. + */ + @Test + void oneToOne_whenOwnerInserted_detailBeanCacheEvicted() { + OCachedO2ODetail d = new OCachedO2ODetail("d-ins"); + DB.save(d); + long dId = d.getId(); + + // populate detail's bean cache with owner=null + DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially no owner").isNull(); + + // insert a new owner pointing at d — must evict d's bean cache + OCachedO2OOwner newOwner = new OCachedO2OOwner("o-ins", DB.reference(OCachedO2ODetail.class, dId)); + DB.save(newOwner); + awaitL2Cache(); + + OCachedO2OOwner ownerAfter = DB.find(OCachedO2ODetail.class, dId).getOwner(); + assertThat(ownerAfter).as("detail owner should be present after owner insert").isNotNull(); + assertThat(ownerAfter.getId()).isEqualTo(newOwner.getId()); + + // cleanup + DB.delete(newOwner); + DB.delete(d); + } + + /** + * When the owning OneToOne FK is changed to point to a different detail (repoint), + * both the old detail's and new detail's bean caches must be evicted. + */ + @Test + void oneToOne_whenOwnerFkRepointed_bothDetailBeanCachesEvicted() { + OCachedO2ODetail d1 = new OCachedO2ODetail("d1-repoint"); + OCachedO2ODetail d2 = new OCachedO2ODetail("d2-repoint"); + DB.save(d1); + DB.save(d2); + OCachedO2OOwner owner = new OCachedO2OOwner("o-repoint", d1); + DB.save(owner); + long d1Id = d1.getId(); + long d2Id = d2.getId(); + + // populate both details' bean caches: d1 with owner reference, d2 with owner=null + DB.find(OCachedO2ODetail.class).setId(d1Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + DB.find(OCachedO2ODetail.class).setId(d2Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + assertThat(DB.find(OCachedO2ODetail.class, d1Id).getOwner()).as("d1 initially owned").isNotNull(); + assertThat(DB.find(OCachedO2ODetail.class, d2Id).getOwner()).as("d2 initially has no owner").isNull(); + + // repoint owner from d1 to d2 — must evict both d1's and d2's bean caches + OCachedO2OOwner ownerRef = DB.find(OCachedO2OOwner.class, owner.getId()); + ownerRef.setDetail(DB.reference(OCachedO2ODetail.class, d2Id)); + DB.save(ownerRef); + awaitL2Cache(); + + assertThat(DB.find(OCachedO2ODetail.class, d1Id).getOwner()) + .as("d1 owner after repoint (should be null)").isNull(); + assertThat(DB.find(OCachedO2ODetail.class, d2Id).getOwner()) + .as("d2 owner after repoint (should be the owner)").isNotNull(); + + // cleanup + DB.delete(ownerRef); + DB.delete(d1); + DB.delete(d2); + } + + /** + * Precision: repointng only evicts the old and new detail's bean cache entry; + * a third, unrelated detail's bean cache entry must remain untouched. + */ + @Test + void oneToOne_whenOwnerFkRepointed_unrelatedDetailCacheUnaffected() { + OCachedO2ODetail d1 = new OCachedO2ODetail("d1-prec"); + OCachedO2ODetail d2 = new OCachedO2ODetail("d2-prec"); + OCachedO2ODetail d3 = new OCachedO2ODetail("d3-prec"); + DB.save(d1); + DB.save(d2); + DB.save(d3); + OCachedO2OOwner owner = new OCachedO2OOwner("o-prec", d1); + DB.save(owner); + long d1Id = d1.getId(); + long d2Id = d2.getId(); + long d3Id = d3.getId(); + + // populate all three details in bean cache + DB.find(OCachedO2ODetail.class).setId(d1Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + DB.find(OCachedO2ODetail.class).setId(d2Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + DB.find(OCachedO2ODetail.class).setId(d3Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + + // confirm d3 is in bean cache before the repoint + ServerCache beanCache = DB.cacheManager().beanCache(OCachedO2ODetail.class); + assertThat(beanCache.get(String.valueOf(d3Id))).as("d3 bean cache populated before repoint").isNotNull(); + + // repoint owner from d1 to d2 (only d1 and d2 should be evicted from bean cache) + OCachedO2OOwner ownerRef = DB.find(OCachedO2OOwner.class, owner.getId()); + ownerRef.setDetail(DB.reference(OCachedO2ODetail.class, d2Id)); + DB.save(ownerRef); + awaitL2Cache(); + + // d3's bean cache entry must still be present + assertThat(beanCache.get(String.valueOf(d3Id))) + .as("d3 bean cache must not be evicted by unrelated repoint").isNotNull(); + + // cleanup + DB.delete(ownerRef); + DB.delete(d1); + DB.delete(d2); + DB.delete(d3); + } + + /** + * When an owning OneToOne bean is deleted (by bean), the detail's bean cache + * must be evicted so the next read reflects that the link is gone. + */ + @Test + void oneToOne_whenOwnerDeletedByBean_detailBeanCacheEvicted() { + OCachedO2ODetail d = new OCachedO2ODetail("d-del-bean"); + DB.save(d); + OCachedO2OOwner owner = new OCachedO2OOwner("o-del-bean", d); + DB.save(owner); + long dId = d.getId(); + + // populate detail's bean cache with the owner reference + DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially owned").isNotNull(); + + // delete the owner by bean — must evict d's bean cache + DB.delete(DB.find(OCachedO2OOwner.class, owner.getId())); + awaitL2Cache(); + + assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()) + .as("detail owner should be null after owner delete (by bean)").isNull(); + + // cleanup + DB.delete(d); + } + + /** + * When an owning OneToOne bean is deleted by id (no bean loaded), the detail's bean + * cache must be cleared so the next read reflects that the link is gone. + */ + @Test + void oneToOne_whenOwnerDeletedById_detailBeanCacheCleared() { + OCachedO2ODetail d = new OCachedO2ODetail("d-del-id"); + DB.save(d); + OCachedO2OOwner owner = new OCachedO2OOwner("o-del-id", d); + DB.save(owner); + long dId = d.getId(); + + // populate detail's bean cache with the owner reference + DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially owned").isNotNull(); + + // delete the owner by id (no bean loaded) — must clear d's entire bean cache + DB.delete(OCachedO2OOwner.class, owner.getId()); + awaitL2Cache(); + + assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()) + .as("detail owner should be null after owner delete (by id)").isNull(); + + // cleanup + DB.delete(d); + } + + // ---- non-cached target: no errors, no unintended eviction ---- + + /** + * When the target detail has no {@code @Cache}, {@code cacheNotifyOwningOneToOne} is false + * and all persist operations are no-ops for cache purposes — no exception, no unintended clearing. + */ + @Test + void oneToOne_withNonCachedDetail_insertUpdateDelete_noErrors() { + OCachedO2ODetailNoCached d = new OCachedO2ODetailNoCached("d-nc"); + DB.save(d); + + // insert owner → no cache eviction, no error + OCachedO2OOwnerNoCachedDetail owner = new OCachedO2OOwnerNoCachedDetail("o-nc", d); + DB.save(owner); + + // update owner FK → no cache eviction, no error + OCachedO2ODetailNoCached d2 = new OCachedO2ODetailNoCached("d2-nc"); + DB.save(d2); + OCachedO2OOwnerNoCachedDetail ownerRef = DB.find(OCachedO2OOwnerNoCachedDetail.class, owner.getId()); + ownerRef.setDetail(d2); + DB.save(ownerRef); + + // delete owner → no cache eviction, no error + DB.delete(ownerRef); + + // delete-by-id → no cache eviction, no error + OCachedO2OOwnerNoCachedDetail o2 = new OCachedO2OOwnerNoCachedDetail("o2-nc", d2); + DB.save(o2); + DB.delete(OCachedO2OOwnerNoCachedDetail.class, o2.getId()); + + // verify the cached detail's bean cache is NOT affected by the non-cached owner operations + OCachedO2ODetail cachedDetail = new OCachedO2ODetail("still-cached"); + DB.save(cachedDetail); + DB.find(OCachedO2ODetail.class).setId(cachedDetail.getId()).setBeanCacheMode(CacheMode.PUT).findOne(); + ServerCache beanCache = DB.cacheManager().beanCache(OCachedO2ODetail.class); + assertThat(beanCache.get(String.valueOf(cachedDetail.getId()))) + .as("unrelated cached detail's bean cache must not be wiped by non-cached owner operations").isNotNull(); + + // cleanup + DB.delete(cachedDetail); + DB.delete(d); + DB.delete(d2); + } + + // ---- SQL/bulk update path (persistTableIUD) ---- + + /** + * When the owner table is updated via a raw SQL update, the detail's bean cache + * must be cleared because we cannot know which FKs changed. + */ + @Test + void oneToOne_whenOwnerTableBulkUpdated_detailBeanCacheCleared() { + OCachedO2ODetail d = new OCachedO2ODetail("d-sql"); + DB.save(d); + OCachedO2OOwner owner = new OCachedO2OOwner("o-sql", d); + DB.save(owner); + long dId = d.getId(); + + // populate detail's bean cache with owner reference + DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne(); + assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially owned").isNotNull(); + + // raw SQL update on the owner table — triggers persistTableIUD which must clear detail's bean cache + SqlUpdate update = DB.sqlUpdate("update o_cached_o2o_owner set name = 'sql-updated' where id = :id"); + update.setParameter("id", owner.getId()); + update.execute(); + awaitL2Cache(); + + // detail's bean cache should have been cleared (entire cache, since we don't know which FKs changed) + ServerCache beanCache = DB.cacheManager().beanCache(OCachedO2ODetail.class); + assertThat(beanCache.get(String.valueOf(dId))) + .as("detail bean cache should be cleared after bulk SQL update on owner table").isNull(); + + // cleanup + DB.delete(owner); + DB.delete(d); + } +} diff --git a/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetail.java b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetail.java new file mode 100644 index 000000000..2b3127f41 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetail.java @@ -0,0 +1,51 @@ +package org.tests.model.cache.o2o; + +import io.ebean.annotation.Cache; + +import jakarta.persistence.*; + +/** + * Cached entity that is the inverse (exported) side of a OneToOne. + * Used to verify that when the owning side's FK changes, this entity's bean cache entry is evicted. + */ +@Cache +@Entity +@Table(name = "o_cached_o2o_detail") +public class OCachedO2ODetail { + + @Id + Long id; + + String name; + + @OneToOne(mappedBy = "detail") + OCachedO2OOwner owner; + + public OCachedO2ODetail(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public OCachedO2OOwner getOwner() { + return owner; + } + + public void setOwner(OCachedO2OOwner owner) { + this.owner = owner; + } +} diff --git a/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetailNoCached.java b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetailNoCached.java new file mode 100644 index 000000000..27a89595f --- /dev/null +++ b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2ODetailNoCached.java @@ -0,0 +1,48 @@ +package org.tests.model.cache.o2o; + +import jakarta.persistence.*; + +/** + * Detail entity WITHOUT {@code @Cache} — the owning {@link OCachedO2OOwnerNoCachedDetail} + * must not trigger any bean cache eviction because there is no bean cache to evict. + */ +@Entity +@Table(name = "o_cached_o2o_detail_nc") +public class OCachedO2ODetailNoCached { + + @Id + Long id; + + String name; + + @OneToOne(mappedBy = "detail") + OCachedO2OOwnerNoCachedDetail owner; + + public OCachedO2ODetailNoCached(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public OCachedO2OOwnerNoCachedDetail getOwner() { + return owner; + } + + public void setOwner(OCachedO2OOwnerNoCachedDetail owner) { + this.owner = owner; + } +} diff --git a/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwner.java b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwner.java new file mode 100644 index 000000000..4f85dcaa6 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwner.java @@ -0,0 +1,49 @@ +package org.tests.model.cache.o2o; + +import jakarta.persistence.*; + +/** + * Owning side of a OneToOne targeting a cached entity (OCachedO2ODetail). + * When this bean is inserted, updated, or deleted the target's bean cache must be evicted. + */ +@Entity +@Table(name = "o_cached_o2o_owner") +public class OCachedO2OOwner { + + @Id + Long id; + + String name; + + @OneToOne + OCachedO2ODetail detail; + + public OCachedO2OOwner(String name, OCachedO2ODetail detail) { + this.name = name; + this.detail = detail; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public OCachedO2ODetail getDetail() { + return detail; + } + + public void setDetail(OCachedO2ODetail detail) { + this.detail = detail; + } +} diff --git a/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwnerNoCachedDetail.java b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwnerNoCachedDetail.java new file mode 100644 index 000000000..910ccddf6 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/model/cache/o2o/OCachedO2OOwnerNoCachedDetail.java @@ -0,0 +1,49 @@ +package org.tests.model.cache.o2o; + +import jakarta.persistence.*; + +/** + * Owning side of a OneToOne whose target ({@link OCachedO2ODetailNoCached}) has no {@code @Cache}. + * Used to verify that when the target is not cached, no eviction (and no error) occurs. + */ +@Entity +@Table(name = "o_cached_o2o_owner_nc") +public class OCachedO2OOwnerNoCachedDetail { + + @Id + Long id; + + String name; + + @OneToOne + OCachedO2ODetailNoCached detail; + + public OCachedO2OOwnerNoCachedDetail(String name, OCachedO2ODetailNoCached detail) { + this.name = name; + this.detail = detail; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public OCachedO2ODetailNoCached getDetail() { + return detail; + } + + public void setDetail(OCachedO2ODetailNoCached detail) { + this.detail = detail; + } +}