diff --git a/hibernate-core/src/main/java/org/hibernate/action/internal/EntityDeleteAction.java b/hibernate-core/src/main/java/org/hibernate/action/internal/EntityDeleteAction.java index cb1b8c6ae0c6..9a4fbd366810 100644 --- a/hibernate-core/src/main/java/org/hibernate/action/internal/EntityDeleteAction.java +++ b/hibernate-core/src/main/java/org/hibernate/action/internal/EntityDeleteAction.java @@ -23,6 +23,7 @@ import org.hibernate.event.spi.PreDeleteEventListener; import org.hibernate.metamodel.mapping.NaturalIdMapping; import org.hibernate.persister.entity.EntityPersister; +import org.hibernate.stat.internal.StatsHelper; import org.hibernate.stat.spi.StatisticsImplementor; /** @@ -297,6 +298,14 @@ protected void removeCacheItem(Object ck) { final EntityPersister persister = getPersister(); if ( persister.canWriteToCache() ) { persister.getCacheAccessStrategy().remove( getSession(), ck ); + + final StatisticsImplementor statistics = getSession().getFactory().getStatistics(); + if ( statistics.isStatisticsEnabled() ) { + statistics.entityCacheRemove( + StatsHelper.getRootEntityRole( persister ), + getPersister().getCacheAccessStrategy().getRegion().getName() + ); + } } } } diff --git a/hibernate-core/src/main/java/org/hibernate/annotations/OnDelete.java b/hibernate-core/src/main/java/org/hibernate/annotations/OnDelete.java index d188df767b84..2af1d82dc111 100644 --- a/hibernate-core/src/main/java/org/hibernate/annotations/OnDelete.java +++ b/hibernate-core/src/main/java/org/hibernate/annotations/OnDelete.java @@ -15,10 +15,33 @@ /** * Specifies an {@code on delete} action for a foreign key constraint. * The most common usage is {@code @OnDelete(action = CASCADE)}. + *
+ * @ManyToOne
+ * @OnDelete(action = CASCADE)
+ * Parent parent;
+ * 
* Note that this results in an {@code on delete cascade} clause in * the DDL definition of the foreign key. It's completely different * to {@link jakarta.persistence.CascadeType#REMOVE}. *

+ * In fact, {@code @OnDelete} may be combined with {@code cascade=REMOVE}. + *

+ * @ManyToOne(cascade = REMOVE)
+ * @OnDelete(action = CASCADE)
+ * Parent parent;
+ * 
+ * + *

* Like database triggers, {@code on delete} actions can cause state * held in memory to lose synchronization with the database. * diff --git a/hibernate-core/src/main/java/org/hibernate/stat/CacheRegionStatistics.java b/hibernate-core/src/main/java/org/hibernate/stat/CacheRegionStatistics.java index c65951ef6091..21441b7e48dc 100644 --- a/hibernate-core/src/main/java/org/hibernate/stat/CacheRegionStatistics.java +++ b/hibernate-core/src/main/java/org/hibernate/stat/CacheRegionStatistics.java @@ -39,6 +39,11 @@ public interface CacheRegionStatistics extends Serializable { */ long getMissCount(); + /** + * The number of removals since the last Statistics clearing + */ + long getRemoveCount(); + /** * The number of elements currently in memory within the cache provider. *

diff --git a/hibernate-core/src/main/java/org/hibernate/stat/CacheableDataStatistics.java b/hibernate-core/src/main/java/org/hibernate/stat/CacheableDataStatistics.java index c50de4cf5fdf..bdf0e01b8c7e 100644 --- a/hibernate-core/src/main/java/org/hibernate/stat/CacheableDataStatistics.java +++ b/hibernate-core/src/main/java/org/hibernate/stat/CacheableDataStatistics.java @@ -36,4 +36,10 @@ public interface CacheableDataStatistics extends Serializable { * configured cache region since the last Statistics clearing */ long getCacheMissCount(); + + /** + * The number of evictions from the configured cache region since + * the last Statistics clearing + */ + long getCacheRemoveCount(); } diff --git a/hibernate-core/src/main/java/org/hibernate/stat/internal/AbstractCacheableDataStatistics.java b/hibernate-core/src/main/java/org/hibernate/stat/internal/AbstractCacheableDataStatistics.java index c90c6c3bcac6..5ca77cb0cb4a 100644 --- a/hibernate-core/src/main/java/org/hibernate/stat/internal/AbstractCacheableDataStatistics.java +++ b/hibernate-core/src/main/java/org/hibernate/stat/internal/AbstractCacheableDataStatistics.java @@ -21,6 +21,7 @@ public abstract class AbstractCacheableDataStatistics implements CacheableDataSt private final @Nullable LongAdder cacheHitCount; private final @Nullable LongAdder cacheMissCount; private final @Nullable LongAdder cachePutCount; + private final @Nullable LongAdder cacheRemoveCount; public AbstractCacheableDataStatistics(Supplier<@Nullable Region> regionSupplier) { final Region region = regionSupplier.get(); @@ -29,12 +30,14 @@ public AbstractCacheableDataStatistics(Supplier<@Nullable Region> regionSupplier this.cacheHitCount = null; this.cacheMissCount = null; this.cachePutCount = null; + this.cacheRemoveCount = null; } else { this.cacheRegionName = region.getName(); this.cacheHitCount = new LongAdder(); this.cacheMissCount = new LongAdder(); this.cachePutCount = new LongAdder(); + this.cacheRemoveCount = new LongAdder(); } } @@ -43,6 +46,7 @@ public AbstractCacheableDataStatistics(Supplier<@Nullable Region> regionSupplier return cacheRegionName; } + @Override public long getCacheHitCount() { if ( cacheRegionName == null ) { return NOT_CACHED_COUNT; @@ -51,6 +55,7 @@ public long getCacheHitCount() { return NullnessUtil.castNonNull( cacheHitCount ).sum(); } + @Override public long getCachePutCount() { if ( cacheRegionName == null ) { return NOT_CACHED_COUNT; @@ -59,6 +64,7 @@ public long getCachePutCount() { return NullnessUtil.castNonNull( cachePutCount ).sum(); } + @Override public long getCacheMissCount() { if ( cacheRegionName == null ) { return NOT_CACHED_COUNT; @@ -67,6 +73,15 @@ public long getCacheMissCount() { return NullnessUtil.castNonNull( cacheMissCount ).sum(); } + @Override + public long getCacheRemoveCount() { + if ( cacheRegionName == null ) { + return NOT_CACHED_COUNT; + } + + return NullnessUtil.castNonNull( cacheRemoveCount ).sum(); + } + public void incrementCacheHitCount() { if ( cacheRegionName == null ) { throw new IllegalStateException( "Illegal attempt to increment cache hit count for non-cached data" ); @@ -91,6 +106,14 @@ public void incrementCachePutCount() { NullnessUtil.castNonNull( cachePutCount ).increment(); } + public void incrementCacheRemoveCount() { + if ( cacheRegionName == null ) { + throw new IllegalStateException( "Illegal attempt to increment cache put count for non-cached data" ); + } + + NullnessUtil.castNonNull( cacheRemoveCount ).increment(); + } + protected void appendCacheStats(StringBuilder buf) { buf.append( ",cacheRegion=" ).append( cacheRegionName ); @@ -100,7 +123,8 @@ protected void appendCacheStats(StringBuilder buf) { buf.append( ",cacheHitCount=" ).append( getCacheHitCount() ) .append( ",cacheMissCount=" ).append( getCacheMissCount() ) - .append( ",cachePutCount=" ).append( getCachePutCount() ); + .append( ",cachePutCount=" ).append( getCachePutCount() ) + .append( ",cacheRemoveCount=" ).append( getCacheRemoveCount() ); } } diff --git a/hibernate-core/src/main/java/org/hibernate/stat/internal/CacheRegionStatisticsImpl.java b/hibernate-core/src/main/java/org/hibernate/stat/internal/CacheRegionStatisticsImpl.java index 13e872e531f8..f76ab27262a4 100644 --- a/hibernate-core/src/main/java/org/hibernate/stat/internal/CacheRegionStatisticsImpl.java +++ b/hibernate-core/src/main/java/org/hibernate/stat/internal/CacheRegionStatisticsImpl.java @@ -22,6 +22,7 @@ public class CacheRegionStatisticsImpl implements CacheRegionStatistics, Seriali private final LongAdder hitCount = new LongAdder(); private final LongAdder missCount = new LongAdder(); private final LongAdder putCount = new LongAdder(); + private final LongAdder removeCount = new LongAdder(); CacheRegionStatisticsImpl(Region region) { this.region = region; @@ -47,6 +48,11 @@ public long getPutCount() { return putCount.sum(); } + @Override + public long getRemoveCount() { + return removeCount.sum(); + } + @Override public long getElementCountInMemory() { return region instanceof ExtendedStatisticsSupport extended @@ -80,6 +86,11 @@ void incrementPutCount() { putCount.increment(); } + + public void incrementRemoveCount() { + removeCount.increment(); + } + @Override public String toString() { String buf = "CacheRegionStatistics" + @@ -87,6 +98,7 @@ public String toString() { ",hitCount=" + this.hitCount + ",missCount=" + this.missCount + ",putCount=" + this.putCount + + ",removeCount=" + this.removeCount + ",elementCountInMemory=" + this.getElementCountInMemory() + ",elementCountOnDisk=" + this.getElementCountOnDisk() + ",sizeInMemory=" + this.getSizeInMemory() + diff --git a/hibernate-core/src/main/java/org/hibernate/stat/internal/StatisticsImpl.java b/hibernate-core/src/main/java/org/hibernate/stat/internal/StatisticsImpl.java index 9f293cefa59e..6e86c06df019 100644 --- a/hibernate-core/src/main/java/org/hibernate/stat/internal/StatisticsImpl.java +++ b/hibernate-core/src/main/java/org/hibernate/stat/internal/StatisticsImpl.java @@ -355,6 +355,13 @@ public void entityCacheMiss(NavigableRole entityName, String regionName) { getEntityStatistics( entityName.getFullPath() ).incrementCacheMissCount(); } + @Override + public void entityCacheRemove(NavigableRole entityName, String regionName) { + secondLevelCacheMissCount.increment(); + getDomainDataRegionStatistics( regionName ).incrementRemoveCount(); + getEntityStatistics( entityName.getFullPath() ).incrementCacheRemoveCount(); + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Collection stats diff --git a/hibernate-core/src/main/java/org/hibernate/stat/spi/StatisticsImplementor.java b/hibernate-core/src/main/java/org/hibernate/stat/spi/StatisticsImplementor.java index c326aa9eb1be..bd32fbfb25c8 100644 --- a/hibernate-core/src/main/java/org/hibernate/stat/spi/StatisticsImplementor.java +++ b/hibernate-core/src/main/java/org/hibernate/stat/spi/StatisticsImplementor.java @@ -148,24 +148,31 @@ public interface StatisticsImplementor extends Statistics, Service { /** * Callback indicating a put into second level cache. * - * @apiNote `entityName` should be the root entity name + * @apiNote {@code entityName} should be the root entity name */ void entityCachePut(NavigableRole entityName, String regionName); /** * Callback indicating a get from second level cache resulted in a hit. * - * @apiNote `entityName` should be the root entity name + * @apiNote {@code entityName} should be the root entity name */ void entityCacheHit(NavigableRole entityName, String regionName); /** * Callback indicating a get from second level cache resulted in a miss. * - * @apiNote `entityName` should be the root entity name + * @apiNote {@code entityName} should be the root entity name */ void entityCacheMiss(NavigableRole entityName, String regionName); + /** + * Callback indicating a removal from second level cache. + * + * @apiNote {@code entityName} should be the root entity name + */ + void entityCacheRemove(NavigableRole rootEntityRole, String name); + /** * Callback indicating a put into second level cache. * diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteCascadeRemoveTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteCascadeRemoveTest.java new file mode 100644 index 000000000000..55552dce01e9 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteCascadeRemoveTest.java @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.ondeletecascade; + +import jakarta.persistence.Cacheable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import org.hibernate.Hibernate; +import org.hibernate.SessionFactory; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.stat.EntityStatistics; +import org.hibernate.stat.Statistics; +import org.hibernate.testing.orm.junit.EntityManagerFactoryScope; +import org.hibernate.testing.orm.junit.Jpa; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Jpa(annotatedClasses = + {OnDeleteCascadeRemoveTest.Parent.class, + OnDeleteCascadeRemoveTest.Child.class}, + generateStatistics = true, + useCollectingStatementInspector = true) +class OnDeleteCascadeRemoveTest { + @Test + void testOnDeleteCascadeRemove1(EntityManagerFactoryScope scope) { + Statistics statistics = + scope.getEntityManagerFactory().unwrap( SessionFactory.class ) + .getStatistics(); + statistics.clear(); + scope.getCollectingStatementInspector().clear(); + scope.inTransaction( em -> { + Parent parent = new Parent(); + Child child = new Child(); + parent.children.add( child ); + child.parent = parent; + em.persist( parent ); + } ); + scope.inTransaction( em -> { + Parent parent = em.find( Parent.class, 0L ); + assertFalse( Hibernate.isInitialized( parent.children ) ); + em.remove( parent ); + // note: ideally we would skip the initialization here + assertTrue( Hibernate.isInitialized( parent.children ) ); + }); + EntityStatistics entityStatistics = statistics.getEntityStatistics( Child.class.getName() ); + assertEquals( 1L, entityStatistics.getDeleteCount() ); + assertEquals( 1L, entityStatistics.getCacheRemoveCount() ); + assertEquals( 5, scope.getCollectingStatementInspector().getSqlQueries().size() ); + long children = + scope.fromTransaction( em -> em.createQuery( "select count(*) from CascadeChild", Long.class ) + .getSingleResult() ); + assertEquals( 0L, children ); + } + + @Test + void testOnDeleteCascadeRemove2(EntityManagerFactoryScope scope) { + Statistics statistics = + scope.getEntityManagerFactory().unwrap( SessionFactory.class ) + .getStatistics(); + statistics.clear(); + scope.getCollectingStatementInspector().clear(); + scope.inTransaction( em -> { + Parent parent = new Parent(); + Child child = new Child(); + parent.children.add( child ); + child.parent = parent; + em.persist( parent ); + } ); + scope.inTransaction( em -> { + Parent parent = em.find( Parent.class, 0L ); + assertEquals(1, parent.children.size()); + assertTrue( Hibernate.isInitialized( parent.children ) ); + em.remove( parent ); + assertTrue( em.unwrap( SessionImplementor.class ) + .getPersistenceContext() + .getEntry( parent.children.iterator().next() ) + .getStatus().isDeletedOrGone() ); + }); + EntityStatistics entityStatistics = statistics.getEntityStatistics( Child.class.getName() ); + assertEquals( 1L, entityStatistics.getDeleteCount() ); + assertEquals( 1L, entityStatistics.getCacheRemoveCount() ); + assertEquals( 5, scope.getCollectingStatementInspector().getSqlQueries().size() ); + long children = + scope.fromTransaction( em -> em.createQuery( "select count(*) from CascadeChild", Long.class ) + .getSingleResult() ); + assertEquals( 0L, children ); + } + + @Entity(name="CascadeParent") + static class Parent { + @Id + long id; + @OneToMany(mappedBy = "parent", + cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) + @OnDelete(action = OnDeleteAction.CASCADE) + Set children = new HashSet<>(); + } + @Entity(name="CascadeChild") + @Cacheable + @SQLDelete( sql = "should never happen" ) + static class Child { + @Id + long id; + @OnDelete(action = OnDeleteAction.CASCADE) + @ManyToOne + Parent parent; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/OnDeleteTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteTest.java similarity index 98% rename from hibernate-core/src/test/java/org/hibernate/orm/test/OnDeleteTest.java rename to hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteTest.java index 9234974a66e2..df3135a797fc 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/OnDeleteTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteTest.java @@ -2,7 +2,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright Red Hat Inc. and Hibernate Authors */ -package org.hibernate.orm.test; +package org.hibernate.orm.test.ondeletecascade; import jakarta.persistence.Entity; import jakarta.persistence.Id; diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/OnDeleteTest2.java b/hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteTest2.java similarity index 98% rename from hibernate-core/src/test/java/org/hibernate/orm/test/OnDeleteTest2.java rename to hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteTest2.java index 9d5cc3aeb3aa..70c115eea469 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/OnDeleteTest2.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/ondeletecascade/OnDeleteTest2.java @@ -2,7 +2,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright Red Hat Inc. and Hibernate Authors */ -package org.hibernate.orm.test; +package org.hibernate.orm.test.ondeletecascade; import jakarta.persistence.Entity; import jakarta.persistence.Id;