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. * *

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. * *

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 first; try (Session s1 = sessionFactory.openSession()) { Query q = s1.createQuery(hql, UncachedProduct.class); q.setCacheable(true); first = q.list(); } long queriesForFirstRun = stats.getPrepareStatementCount(); long queryCacheMissesAfterFirst = stats.getQueryCacheMissCount(); stats.clear(); List second; try (Session s2 = sessionFactory.openSession()) { Query 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(); } }