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