Add Hibernate 7 batches 2-6 and batch 7: 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, and Hibernate Validator CDI integration (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
2026-09-20 04:56:34 +00:00
committed by Claude
co-authored by Claude Sonnet 5
commit 4292f1cd54
283 changed files with 21259 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);
}
}
@@ -0,0 +1,63 @@
package com.ankurm.hibernatedemo.cache;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
/**
* The original article claimed: "Without the jakarta classifier, Ehcache 3 ships with the older
* javax.cache JCache API. Hibernate 7 requires the jakarta.cache namespace. Using the wrong
* artifact causes a ClassNotFoundException or NoSuchMethodError at startup." That claim is
* checked here directly against the classpath rather than repeated.
*
* <p>JSR-107 (JCache) was never migrated to the Jakarta namespace by its spec maintainers --
* unlike JPA, Bean Validation, or Servlet. {@code javax.cache.Caching} is the one and only API
* class, with or without Ehcache's own "jakarta" classifier. That classifier is Ehcache's own
* internal choice of JAXB runtime major version (used to parse its own {@code ehcache.xml}), not
* a JCache API namespace switch -- confirmed by comparing the two classifier jars' Gradle module
* metadata (one depends on {@code jaxb-runtime [2.2,3)}, the other on {@code [3,3.1)}) and by
* disassembling {@code ConfigurationParser.class} in both jars, which import
* {@code javax.xml.bind} and {@code jakarta.xml.bind} respectively -- never {@code javax.cache}
* or a {@code jakarta.cache} package, because the latter does not exist.
*
* <p>Docs: docs/18-ehcache-l2-configuration.md
*/
class CacheApiNamespaceTest {
@Test
void javaxCacheApi_isOnTheClasspath_regardlessOfEhcachesJakartaClassifier() throws ClassNotFoundException {
Class<?> caching = Class.forName("javax.cache.Caching");
Class<?> cacheManager = Class.forName("javax.cache.CacheManager");
System.out.println("RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath "
+ "(jar: " + caching.getProtectionDomain().getCodeSource().getLocation() + ")");
assertThat(caching).isNotNull();
assertThat(cacheManager).isNotNull();
}
@Test
void jakartaCacheNamespace_doesNotExist_onThisClasspathOrAnyOther() {
Throwable thrown = catchClassNotFound("jakarta.cache.Cache");
System.out.println("RESULT[cache-api-no-jakarta-namespace]: Class.forName(\"jakarta.cache.Cache\") -> "
+ thrown.getClass().getSimpleName()
+ " -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's "
+ "own \"jakarta\" classifier on org.ehcache:ehcache.");
assertThatThrownBy(() -> Class.forName("jakarta.cache.Cache"))
.as("no jakarta.cache package has ever existed -- JCache (JSR-107) kept the javax.cache "
+ "namespace even after Jakarta EE 9's javax->jakarta rename")
.isInstanceOf(ClassNotFoundException.class);
}
private static Throwable catchClassNotFound(String className) {
try {
Class.forName(className);
return null;
} catch (Throwable t) {
return t;
}
}
}
@@ -0,0 +1,105 @@
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;
/**
* The ordinary case the original article's {@code Product} class was built around: PK lookups
* of a {@code @Cacheable}/{@code @Cache}-annotated entity, served from Ehcache after the first
* session closes, following the same isolated-SessionFactory pattern as chapter 06's
* {@code NaturalIdL2CacheTest} so this never touches the shared Spring context.
*
* <p>Docs: docs/18-ehcache-l2-configuration.md
*/
class EntityL2CacheTest {
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:entityl2;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 getById_fromABrandNewSession_isServedFromL2WithNoSql() {
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();
}
stats.clear();
try (Session s1 = sessionFactory.openSession()) {
CacheProduct p = s1.get(CacheProduct.class, id);
assertThat(p.getName()).isEqualTo("Laptop Pro");
}
long queriesAfterSession1 = stats.getPrepareStatementCount();
try (Session s2 = sessionFactory.openSession()) {
CacheProduct p = s2.get(CacheProduct.class, id);
assertThat(p.getName()).isEqualTo("Laptop Pro");
}
long queriesAfterSession2 = stats.getPrepareStatementCount();
long l2HitsAfterSession2 = stats.getSecondLevelCacheHitCount();
System.out.println("RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries="
+ queriesAfterSession1 + " | session2 (brand-new session, same id) cumulative queries="
+ queriesAfterSession2 + ", L2 entity cache hits=" + l2HitsAfterSession2);
// Surprise, and the same one chapter 06 already documented for @NaturalIdCache: persist()
// + commit() populates the L2 entity cache region itself, before anyone ever calls get().
// So "session1" here is ALREADY a cache hit, not a cold DB read -- there is no query left
// for session1's own get() to save. This generalizes chapter 06's natural-id finding to
// plain @Cacheable/@Cache entity caching too, not just @NaturalIdCache.
assertThat(queriesAfterSession1)
.as("persist()+commit() already populated the L2 region -- session1's get() fires no SQL")
.isZero();
assertThat(queriesAfterSession2)
.as("session2, a brand-new session, must not fire any query either -- served from L2")
.isEqualTo(queriesAfterSession1);
assertThat(l2HitsAfterSession2)
.as("both get() calls (session1 and session2) register as L2 entity cache hits")
.isEqualTo(2L);
}
}
@@ -0,0 +1,119 @@
package com.ankurm.hibernatedemo.cache;
import static org.assertj.core.api.Assertions.assertThat;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.junit.jupiter.api.Test;
/**
* Checks the original article's claim -- "Missing default-update-timestamps-region: Required for
* the Query Cache -- omitting it causes startup errors" -- by actually omitting it, rather than
* repeating the warning unverified. The claim is FALSE for this stack: {@code SessionFactory}
* builds with no error at all.
*
* <p>{@code hibernate-jcache}'s default {@code MissingCacheStrategy} is {@code CREATE_WARN}
* (external representation {@code "create-warn"}, confirmed by disassembling {@code
* MissingCacheStrategy.class} in {@code hibernate-jcache-7.4.5.Final.jar}): a missing region is
* created on the fly with provider-specific default policies, and Hibernate only logs {@code
* HHH90001006}. Setting {@code hibernate.javax.cache.missing_cache_strategy} to {@code "fail"}
* turns this into the hard startup error the article describes; {@code "create"} keeps the
* auto-create behavior but silences the warning.
*
* <p>Docs: docs/18-ehcache-l2-configuration.md
*/
class MissingUpdateTimestampsRegionTest {
@Test
void queryCacheEnabled_withNoUpdateTimestampsRegionInEhcacheXml_buildsFineWithOnlyAWarning() {
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestamps;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.hbm2ddl.auto", "create")
.applySetting("hibernate.cache.use_second_level_cache", "true")
.applySetting("hibernate.cache.use_query_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-missing-timestamps.xml")
.build();
try {
Metadata metadata = new MetadataSources(registry)
.addAnnotatedClass(CacheProduct.class)
.buildMetadata();
Throwable thrown = catchThrowableFromBuildSessionFactory(metadata);
System.out.println("RESULT[cache-missing-timestamps-region]: "
+ (thrown == null
? "SessionFactory built with NO error -- contrary to the original article, the "
+ "default MissingCacheStrategy (CREATE_WARN) auto-creates the missing "
+ "default-update-timestamps-region and only logs HHH90001006"
: thrown.getClass().getName() + ": " + rootMessage(thrown)));
assertThat(thrown)
.as("corrected finding: with the default missing_cache_strategy, a missing "
+ "default-update-timestamps-region does NOT fail SessionFactory startup")
.isNull();
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
@Test
void settingMissingCacheStrategyToFail_reproducesTheHardStartupErrorTheArticleDescribed() {
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestampsfail;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.hbm2ddl.auto", "create")
.applySetting("hibernate.cache.use_second_level_cache", "true")
.applySetting("hibernate.cache.use_query_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-missing-timestamps.xml")
.applySetting("hibernate.javax.cache.missing_cache_strategy", "fail")
.build();
try {
Metadata metadata = new MetadataSources(registry)
.addAnnotatedClass(CacheProduct.class)
.buildMetadata();
Throwable thrown = catchThrowableFromBuildSessionFactory(metadata);
System.out.println("RESULT[cache-missing-timestamps-region-strict]: "
+ "hibernate.javax.cache.missing_cache_strategy=fail -> "
+ (thrown == null ? "SessionFactory built with NO error"
: thrown.getClass().getName() + ": " + rootMessage(thrown))
+ " -- this is how to opt into the hard-failure behavior the original article assumed "
+ "was the default.");
assertThat(thrown)
.as("with missing_cache_strategy explicitly set to fail, a missing "
+ "default-update-timestamps-region does cause a startup error -- this is an "
+ "opt-in, not the default")
.isNotNull();
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
private static Throwable catchThrowableFromBuildSessionFactory(Metadata metadata) {
try {
metadata.buildSessionFactory().close();
return null;
} catch (Throwable t) {
return t;
}
}
private static String rootMessage(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getMessage();
}
}
@@ -0,0 +1,138 @@
package com.ankurm.hibernatedemo.cache;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
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.query.Query;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
/**
* Checks the original article's claim -- "Query Cache without Entity Cache causes N+1 selects" --
* by actually measuring it rather than repeating it. The claim does NOT hold: with {@link
* UncachedProduct} carrying no {@code @Cacheable}/{@code @Cache} at all, a second, brand-new
* session repeating the same {@code setCacheable(true)} query fires ZERO SQL statements, not five.
*
* <p>The query cache region does not store only the row ids. It stores the full hydrated tuple
* state of each result row (id plus every mapped column) at the moment the query first ran.
* Hibernate reconstitutes {@code UncachedProduct} instances directly from that stored tuple data
* on a cache hit -- no re-query of the rows, and (confirmed below via {@code
* getSecondLevelCacheHitCount()}) no dependency on the entity's own L2 region at all, because
* {@link UncachedProduct} does not have one. This test does not rule out an N+1 appearing for a
* query that returns associations Hibernate must still initialize per row, or for a partial-hit
* scenario after individual cache entries are evicted -- only the specific, simple case the
* article described (a flat entity, no associations, a repeated identical query) is measured and
* corrected here.
*
* <p>Docs: docs/18-ehcache-l2-configuration.md
*/
class QueryCacheWithoutEntityCacheTest {
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:querycachenoentity;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.use_query_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(UncachedProduct.class)
.buildMetadata();
sessionFactory = metadata.buildSessionFactory();
}
@AfterEach
void tearDown() {
if (sessionFactory != null) {
sessionFactory.close();
}
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
}
}
@Test
void cachedQuery_overUncachedEntities_repeatRunIsWorseThanNoCachingAtAll() {
boot();
Statistics stats = sessionFactory.getStatistics();
try (Session seed = sessionFactory.openSession()) {
Transaction tx = seed.beginTransaction();
for (int i = 1; i <= 5; i++) {
seed.persist(new UncachedProduct("Widget " + i));
}
tx.commit();
}
String hql = "select p from UncachedProduct p order by p.id";
stats.clear();
List<UncachedProduct> first;
try (Session s1 = sessionFactory.openSession()) {
Query<UncachedProduct> q = s1.createQuery(hql, UncachedProduct.class);
q.setCacheable(true);
first = q.list();
}
long queriesForFirstRun = stats.getPrepareStatementCount();
long queryCacheMissesAfterFirst = stats.getQueryCacheMissCount();
stats.clear();
List<UncachedProduct> second;
try (Session s2 = sessionFactory.openSession()) {
Query<UncachedProduct> q = s2.createQuery(hql, UncachedProduct.class);
q.setCacheable(true);
second = q.list();
}
long queriesForSecondRun = stats.getPrepareStatementCount();
long queryCacheHitsAfterSecond = stats.getQueryCacheHitCount();
long l2EntityHitsAfterSecond = stats.getSecondLevelCacheHitCount();
System.out.println("RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements="
+ queriesForFirstRun + ", query-cache misses=" + queryCacheMissesAfterFirst
+ " | second run (new session, query-cache HIT) SQL statements=" + queriesForSecondRun
+ ", query-cache hits=" + queryCacheHitsAfterSecond
+ ", L2 entity-cache hits=" + l2EntityHitsAfterSecond
+ " -- contrary to the original article, the second run costs ZERO SQL statements: "
+ "the query cache stores the full row tuples, not just the 5 ids, and Hibernate "
+ "rebuilds UncachedProduct instances straight from that stored data. The zero L2 "
+ "entity-cache hits confirm this does not depend on UncachedProduct having its own "
+ "L2 region at all -- it has none.");
assertThat(first).hasSize(5);
assertThat(second).hasSize(5);
assertThat(queriesForFirstRun)
.as("the first, cold run executes the query itself as ONE statement")
.isEqualTo(1);
assertThat(queryCacheHitsAfterSecond)
.as("the second run IS a genuine query-cache hit")
.isEqualTo(1L);
assertThat(queriesForSecondRun)
.as("corrected finding: the second run costs ZERO SQL statements, not the 5 individual "
+ "SELECTs the original article claimed -- the query cache reconstitutes the "
+ "entities directly from its own stored tuple data")
.isZero();
assertThat(l2EntityHitsAfterSecond)
.as("and none of that reconstruction is an L2 entity-cache hit -- UncachedProduct has "
+ "no L2 region to hit, so the mechanism is the query cache's own stored data, "
+ "not a secretly-enabled entity cache")
.isZero();
}
}