Add Hibernate 7 batches 2-6, batch 7, and batch 8: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, Hibernate Validator CDI integration, aggregate functions, sorting, pagination, interceptors, and Hibernate Search 8 (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)

This commit is contained in:
2026-09-20 06:06:42 +00:00
committed by Claude
commit 8568c0ce6c
330 changed files with 23668 additions and 0 deletions
@@ -0,0 +1,195 @@
package com.ankurm.hibernatedemo.cache;
import static org.assertj.core.api.Assertions.assertThat;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
/**
* Checks the original article's claim -- "Bulk HQL mutations bypass cache: a bulk update leaves
* an already-cached entity stale until it is manually evicted" -- against what actually happens.
* The claim is FALSE both for HQL/JPQL bulk statements and, once actually measured, for native
* SQL bulk statements too: a fresh session sees the new value immediately in both cases, with no
* manual eviction step needed for either.
*
* <p>For {@code Session.createMutationQuery(...).executeUpdate()}, verified by disassembling
* {@code hibernate-core-7.4.5.Final.jar}: every bulk HQL/JPQL {@code update}/{@code delete}
* registers a {@code org.hibernate.action.internal.BulkOperationCleanupAction} as an
* after-transaction-completion process. Its {@code EntityCleanup} inner class calls {@code
* EntityDataAccess.lockRegion()} then {@code EntityDataAccess.removeAll(session)} for every
* entity type the statement's "query spaces" (tables) touch. Hibernate knows which entity types
* are affected because {@code createMutationQuery} parses the HQL/JPQL itself.
*
* <p>The natural next question -- and the original hypothesis behind the second test below --
* was whether a bulk update fired as raw/native SQL escapes this, since Hibernate's HQL parser
* never sees it and so cannot name the affected "query spaces" for {@code
* BulkOperationCleanupAction} to key off of. Measuring it disproves the hypothesis: Hibernate's
* actual default for an unqualified native DML statement is the conservative opposite of "do
* nothing" -- unable to prove which regions are safe, it invalidates every region it knows about.
* The entity cache still ends up empty after the statement, just reached by a different path.
*
* <p>Docs: docs/18-ehcache-l2-configuration.md
*/
class BulkUpdateBypassesCacheTest {
private StandardServiceRegistry registry;
private SessionFactory sessionFactory;
private void boot() {
registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:bulkbypass;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.connection.password", "")
.applySetting("hibernate.hbm2ddl.auto", "create")
.applySetting("hibernate.generate_statistics", "true")
.applySetting("hibernate.cache.use_second_level_cache", "true")
.applySetting("hibernate.cache.region.factory_class", "jcache")
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml")
.build();
Metadata metadata = new MetadataSources(registry)
.addAnnotatedClass(CacheProduct.class)
.buildMetadata();
sessionFactory = metadata.buildSessionFactory();
}
@AfterEach
void tearDown() {
if (sessionFactory != null) {
sessionFactory.close();
}
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
}
}
@Test
void bulkHqlUpdate_doesNotLeaveTheL2EntityCacheStale_becauseHibernateAutoEvictsTheRegion() {
boot();
Long id;
try (Session seed = sessionFactory.openSession()) {
Transaction tx = seed.beginTransaction();
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
seed.persist(p);
tx.commit();
id = p.getId();
}
// Populate/confirm the L2 region with the original price via a plain get() in its own session.
try (Session warm = sessionFactory.openSession()) {
CacheProduct p = warm.get(CacheProduct.class, id);
assertThat(p.getPrice()).isEqualTo(1199.0);
}
// Bulk HQL/JPQL UPDATE: Hibernate parses this itself, knows CacheProduct's table is a
// "query space" the statement touches, and registers a BulkOperationCleanupAction that
// evicts CacheProduct's entire L2 region right after this transaction commits.
try (Session bulk = sessionFactory.openSession()) {
Transaction tx = bulk.beginTransaction();
int updated = bulk.createMutationQuery("update CacheProduct set price = :newPrice where id = :id")
.setParameter("newPrice", 999.0)
.setParameter("id", id)
.executeUpdate();
tx.commit();
assertThat(updated).isEqualTo(1);
}
// A brand-new session's get() -- does it see the new price, or the stale cached one?
Double priceSeenAfterBulkUpdate;
try (Session after = sessionFactory.openSession()) {
priceSeenAfterBulkUpdate = after.get(CacheProduct.class, id).getPrice();
}
System.out.println("RESULT[cache-bulk-update-hql-not-stale]: price in the database after the bulk HQL "
+ "update=999.0 | price a brand-new session's get() actually returns=" + priceSeenAfterBulkUpdate
+ " -- contrary to the original article, a bulk HQL update via createMutationQuery does NOT "
+ "leave the L2 cache stale. BulkOperationCleanupAction evicts CacheProduct's entire region "
+ "automatically once the statement's transaction commits, so no manual "
+ "sessionFactory.getCache().evictEntityData(...) call is needed for this case.");
assertThat(priceSeenAfterBulkUpdate)
.as("corrected finding: a fresh session sees the real 999.0 immediately -- the bulk HQL "
+ "update did not leave a stale cache entry behind")
.isEqualTo(999.0);
}
@Test
void bulkNativeSqlUpdate_alsoDoesNotLeaveTheCacheStale_hibernateInvalidatesEverythingItCannotParse() {
boot();
Statistics stats = sessionFactory.getStatistics();
Long id;
try (Session seed = sessionFactory.openSession()) {
Transaction tx = seed.beginTransaction();
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
seed.persist(p);
tx.commit();
id = p.getId();
}
// Populate/confirm the L2 region with the original price via a plain get() in its own session.
try (Session warm = sessionFactory.openSession()) {
CacheProduct p = warm.get(CacheProduct.class, id);
assertThat(p.getPrice()).isEqualTo(1199.0);
}
long l2PutsAfterWarm = stats.getSecondLevelCachePutCount();
// Raw/native SQL UPDATE: Hibernate's HQL parser never sees this statement, so it cannot
// name the specific "query spaces" (tables) it touches the way createMutationQuery does.
// The hypothesis going in was that this means NO cache eviction happens at all -- that
// hypothesis is wrong, and disproven by the assertions below. Hibernate's actual behavior
// for an unqualified native DML statement is the conservative opposite: since it cannot
// prove which regions are safe, it treats every known cache region as a possibly-affected
// query space and invalidates all of them, exactly as if the statement had touched
// everything. This is confirmed here by getSecondLevelCacheHitCount() staying at zero on
// the read after the native update, even though the entity was demonstrably cached going
// in (l2PutsAfterWarm is 1, not 0).
try (Session bulk = sessionFactory.openSession()) {
Transaction tx = bulk.beginTransaction();
int updated = bulk.createNativeQuery("update CacheProduct set price = :newPrice where id = :id")
.setParameter("newPrice", 999.0)
.setParameter("id", id)
.executeUpdate();
tx.commit();
assertThat(updated).isEqualTo(1);
}
long l2HitsBeforeRead = stats.getSecondLevelCacheHitCount();
Double priceSeenAfterNativeUpdate;
try (Session after = sessionFactory.openSession()) {
priceSeenAfterNativeUpdate = after.get(CacheProduct.class, id).getPrice();
}
long l2HitsAfterRead = stats.getSecondLevelCacheHitCount();
System.out.println("RESULT[cache-bulk-update-native-not-stale]: price in the database after the native "
+ "SQL update=999.0 | price a brand-new session's get() actually returns="
+ priceSeenAfterNativeUpdate + " | L2 puts recorded before the native update=" + l2PutsAfterWarm
+ " | L2 hits before/after the post-update read=" + l2HitsBeforeRead + "/" + l2HitsAfterRead
+ " -- the original hypothesis (native SQL bypasses query-space tracking, so the L2 entry "
+ "survives stale) was WRONG. The entity WAS cached (one L2 put), yet the post-update read is "
+ "not an L2 hit at all: Hibernate cannot verify a native statement's affected tables, so it "
+ "conservatively invalidates every region it knows about rather than none of them.");
assertThat(l2PutsAfterWarm)
.as("the entity really was put into the L2 region by the warm-up get()")
.isEqualTo(1L);
assertThat(priceSeenAfterNativeUpdate)
.as("corrected finding: a native SQL update does not leave the cache stale either -- "
+ "Hibernate's conservative default invalidates the region instead")
.isEqualTo(999.0);
assertThat(l2HitsAfterRead)
.as("and the read after the native update is not served from L2 at all -- it is a "
+ "genuine re-query, not a lucky freshly-recomputed cache entry")
.isEqualTo(l2HitsBeforeRead);
}
}