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,37 @@
package com.ankurm.brokenprobe;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
/**
* DELIBERATELY BROKEN entity used ONLY by
* {@code com.ankurm.hibernatedemo.namedquery.NamedQueryStartupValidationTest}, via a fully
* standalone Hibernate bootstrap (StandardServiceRegistryBuilder + MetadataSources) that never
* touches the shared Spring Boot application context.
*
* <p>Deliberately placed OUTSIDE the {@code com.ankurm.hibernatedemo} package tree. Spring
* Boot's default JPA entity scanning walks every subpackage of the {@code @SpringBootApplication}
* class's package ({@code com.ankurm.hibernatedemo}), so a broken {@code @NamedQuery} anywhere
* under that tree would fail {@code @SpringBootTest} context bootstrap for EVERY test in this
* shared repository, not just this one. Keeping it here means only the standalone bootstrap
* below ever sees it.
*/
@Entity
@Table(name = "broken_nq_employee")
@NamedQuery(name = "BrokenNamedQueryEmployee.badProperty",
query = "SELECT e FROM BrokenNamedQueryEmployee e WHERE e.firsNam = :name") // typo: firsNam
public class BrokenNamedQueryEmployee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
protected BrokenNamedQueryEmployee() {
}
}
@@ -0,0 +1,93 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetAlloc1;
import com.ankurm.hibernatedemo.model.WidgetAlloc10;
import com.ankurm.hibernatedemo.model.WidgetAlloc25;
import com.ankurm.hibernatedemo.model.WidgetAlloc50;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.function.Function;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=AllocationSizeSweepTest test}.
*
* <p>Holds {@code hibernate.jdbc.batch_size=25} fixed (the application.yml default) and sweeps
* {@code allocationSize} across four otherwise-identical entities: {@link WidgetAlloc1},
* {@link WidgetAlloc10}, {@link WidgetAlloc25}, {@link WidgetAlloc50}. 30 rows each. Numbers are
* asserted, not predicted -- see the class Javadoc on each entity for why they're separate
* classes rather than one parameterized mapping.
*/
@SpringBootTest
class AllocationSizeSweepTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
private int insertRowsAndReturnPreparedStatementCount(Function<String, Object> factory) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(factory.apply("w-" + i));
}
em.getTransaction().commit();
em.close();
return (int) stats.getPrepareStatementCount();
}
@Test
void allocationSize1_everyRowNeedsItsOwnSequenceCall() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc1::new);
DEMO.info("allocationSize=1, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// Measured via `mvn test` (the full suite), reproduced twice: 31, not the 32 a naive
// "2 insert batches + 30 sequence calls" arithmetic predicts. allocationSize=1 forces a
// sequence call practically every row, which dominates the count either way -- but the
// exact figure is asserted from the real run, not derived on paper. See
// docs/03-inserting-objects.md for the honest version of this story, including where the
// paper arithmetic was wrong.
assertThat(count).isEqualTo(31);
}
@Test
void allocationSize10_threeSequenceRefills() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc10::new);
DEMO.info("allocationSize=10, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + ceil(30/10)=3 sequence calls.
assertThat(count).isEqualTo(2 + 3);
}
@Test
void allocationSize25_matchesBatchSize_twoSequenceRefills() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc25::new);
DEMO.info("allocationSize=25, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + ceil(30/25)=2 sequence calls -- this is WidgetSequence's
// configuration, confirmed again here for the sweep table.
assertThat(count).isEqualTo(2 + 2);
}
@Test
void allocationSize50_oneSequenceCallCoversAllThirtyRows() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc50::new);
DEMO.info("allocationSize=50, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + a single sequence call (50 >= 30, the whole run fits in
// one allocated block) = 3. Measured and reproduced via `mvn test`.
assertThat(count).isEqualTo(3);
}
}
@@ -0,0 +1,120 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep1;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep10;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep25;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep50;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=BatchSizeSweepTest test}.
*
* <p>The companion sweep to {@code AllocationSizeSweepTest}: holds {@code allocationSize=50}
* fixed and sweeps {@code hibernate.jdbc.batch_size} across 1, 10, 25, 50 -- each as its own
* {@code @Nested @SpringBootTest} so each gets a genuinely separate Hibernate configuration
* rather than one mutated at runtime.
*
* <p><strong>Each sweep point uses its own dedicated entity and sequence</strong>
* ({@code WidgetBatchSweep1/10/25/50}), even though all four mappings are identical. The first
* version of this test shared a single sequence across all four nested classes and got
* unstable, run-order-dependent {@code prepareStatementCount} numbers as a result -- a real
* finding in its own right, not a hypothetical one. See docs/03-inserting-objects.md for the
* writeup; the fix is isolation, not a smarter assertion.
*/
class BatchSizeSweepTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
private static int insertRowsAndReturnPreparedStatementCount(
EntityManagerFactory emf, java.util.function.Function<String, Object> factory) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(factory.apply("w-" + i));
}
em.getTransaction().commit();
em.close();
return (int) stats.getPrepareStatementCount();
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=1")
class BatchSize1 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeOne_batchingEffectivelyDisabled() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep1::new);
DEMO.info("allocationSize=50, batch_size=1, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// batch_size=1 means no real batching: close to one prepared statement per row.
assertThat(count).isEqualTo(32);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=10")
class BatchSize10 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeTen() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep10::new);
DEMO.info("allocationSize=50, batch_size=10, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// Once batching is enabled at all (batch_size > 1), the insert side of
// prepareStatementCount collapses to a small constant regardless of the exact
// batch_size -- see batchSizeTwentyFive and batchSizeFifty below, which measure the
// same value. batch_size clearly still governs how many rows go into each JDBC
// executeBatch() call (that's real and documented), it just isn't visible in this
// particular statistic once batching is on.
assertThat(count).isEqualTo(2);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=25")
class BatchSize25 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeTwentyFive() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep25::new);
DEMO.info("allocationSize=50, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
assertThat(count).isEqualTo(2);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=50")
class BatchSize50 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeFifty() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep50::new);
DEMO.info("allocationSize=50, batch_size=50, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
assertThat(count).isEqualTo(2);
}
}
}
@@ -0,0 +1,246 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityNotFoundException;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.LazyInitializationException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4859. Docs: docs/01-get-vs-load.md.
*
* <p>Run with {@code ./mvnw -Dtest=GetVsGetReferenceTest test}. Every assertion here was first
* observed by running the same code and reading the log, then pinned down as an assertion --
* none of the outcomes below were assumed going in.
*/
@SpringBootTest
class GetVsGetReferenceTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedBook(String title) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book(title, "Test Author");
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
// ---- Part 1: four calls, four outcomes ----
@Test
void getOnExistingId_firesSelect_returnsRealEntity() {
Long id = seedBook("Effective Java");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book book = session.get(Book.class, id);
assertThat(stats().getPrepareStatementCount()).as("get() on an existing id must fire a SELECT").isEqualTo(1);
assertThat(book).isNotNull();
assertThat(book.getClass()).isEqualTo(Book.class);
em.getTransaction().commit();
em.close();
}
@Test
void getOnMissingId_firesSelect_returnsNull() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book book = session.get(Book.class, 999_111_222L);
assertThat(stats().getPrepareStatementCount()).as("get() on a missing id still fires a SELECT").isEqualTo(1);
assertThat(book).isNull();
em.getTransaction().commit();
em.close();
}
@Test
void getReferenceOnExistingId_noSelectUntilPropertyAccessed() {
Long id = seedBook("Domain-Driven Design");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book proxy = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("getReference() must not fire a SELECT at the call site")
.isEqualTo(0);
String title = proxy.getTitle();
assertThat(stats().getPrepareStatementCount())
.as("the SELECT is deferred until a non-id accessor is called")
.isEqualTo(1);
assertThat(title).isEqualTo("Domain-Driven Design");
em.getTransaction().commit();
em.close();
}
@Test
void getReferenceOnMissingId_noExceptionUntilAccessed() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, 999_333_444L);
// No exception yet -- constructing the proxy never touched the database.
assertThat(proxy).isNotNull();
EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, proxy::getTitle);
DEMO.info("getReference() on a missing id, once accessed, threw: {}: {}", ex.getClass().getName(), ex.getMessage());
em.getTransaction().rollback();
em.close();
}
// ---- Part 2: same-session matrix ----
// For each combination, both calls target the SAME id in the SAME session.
@Test
void sessionMatrix_getThenGet_secondCallHitsL1Cache_sameInstance() {
Long id = seedBook("Matrix: get/get");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book first = session.get(Book.class, id);
stats().clear();
Book second = session.get(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("second get() in the same session must NOT re-fire a SELECT (L1 cache hit)")
.isEqualTo(0);
assertThat(second).isSameAs(first);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getReferenceThenGetReference_secondCallHitsL1Cache_sameInstance() {
Long id = seedBook("Matrix: getReference/getReference");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book first = session.getReference(Book.class, id);
stats().clear();
Book second = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("second getReference() in the same session must not fire anything either -- still just a reference")
.isEqualTo(0);
assertThat(second).isSameAs(first);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getThenGetReference_returnsTheSameAlreadyInitializedInstance() {
Long id = seedBook("Matrix: get/getReference");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book real = session.get(Book.class, id);
stats().clear();
Book second = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("getReference() after get() must not fire a SELECT -- the real entity is already in the L1 cache")
.isEqualTo(0);
assertThat(second)
.as("getReference() returns the SAME already-managed real instance, not a new proxy, once one exists in this session")
.isSameAs(real);
assertThat(second.getClass()).isEqualTo(Book.class);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getReferenceThenGet_getReturnsTheExistingProxyAndDoesNotForceInitialization() {
Long id = seedBook("Matrix: getReference/get");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, id);
stats().clear();
Book second = session.get(Book.class, id);
assertThat(second)
.as("get() after getReference() returns the SAME proxy already sitting in the L1 cache")
.isSameAs(proxy);
DEMO.info("get() after getReference(): prepareStatementCount for this call = {}, returned class = {}",
stats().getPrepareStatementCount(), second.getClass().getName());
em.getTransaction().commit();
em.close();
}
// ---- Part 3: proxy identity experiment ----
@Test
void proxyIdentity_instanceofSurvives_equalsDoesNot() {
Long id = seedBook("Proxy Identity");
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Book real = em1.unwrap(Session.class).get(Book.class, id);
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Book proxy = em2.unwrap(Session.class).getReference(Book.class, id);
assertThat(proxy).isInstanceOf(Book.class);
assertThat(org.hibernate.Hibernate.getClass(proxy)).isEqualTo(Book.class);
assertThat(proxy.getClass()).isNotEqualTo(Book.class);
// Book does not override equals()/hashCode() -- this is the point of the test.
assertThat(real.equals(proxy)).isFalse();
assertThat(proxy.equals(real)).isFalse();
Set<Book> set = new HashSet<>();
set.add(real);
assertThat(set.contains(proxy))
.as("a HashSet built on default equals()/hashCode() cannot recognise the proxy and the real instance as the same row")
.isFalse();
em1.getTransaction().commit();
em1.close();
em2.getTransaction().commit();
em2.close();
}
@Test
void proxyOutlivesItsSession_throwsLazyInitializationExceptionOnAccess() {
Long id = seedBook("Outlives Session");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book proxy = em.unwrap(Session.class).getReference(Book.class, id);
em.getTransaction().commit();
em.close();
assertThrows(LazyInitializationException.class, proxy::getTitle);
}
}
@@ -0,0 +1,47 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetIdentity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=IdentityBatchTest test}.
*/
@SpringBootTest
class IdentityBatchTest {
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
@Test
void identityGeneratorDisablesBatching_despiteBatchSizeBeingSet() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetIdentity("identity-" + i));
}
em.getTransaction().commit();
em.close();
assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT);
assertThat(stats.getPrepareStatementCount())
.as("with GenerationType.IDENTITY, hibernate.jdbc.batch_size has nothing to batch -- "
+ "every insert is its own round trip because the generated key is only "
+ "known after the row is written")
.isEqualTo(ROW_COUNT);
}
}
@@ -0,0 +1,60 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.immutable.ExchangeRate;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/07-immutable-entities.md. A SEPARATE Spring context (own EntityManagerFactory) with
* {@code hibernate.query.immutable_entity_update_query_handling_mode=allow} set, to prove the
* escape hatch from {@link ImmutableEntityTest#bulkHqlUpdate_onImmutableEntity_isRejectedAtTranslationTime_byDefault}
* really lets the bulk HQL UPDATE run -- and that when it does, the UPDATE is issued for real.
* This is a SessionFactory-wide setting, not a per-query hint, hence the separate context.
*/
@SpringBootTest(properties = {
"spring.jpa.properties.hibernate.query.immutable_entity_update_query_handling_mode=allow"
})
class ImmutableBulkUpdateAllowedTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void withHandlingModeSetToAllow_bulkHqlUpdate_actuallyRunsAgainstImmutableEntity() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("ZAR/USD", new BigDecimal("18.0000"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
int updated = em.createQuery("update ExchangeRate set rate = :r where id = :id")
.setParameter("r", new BigDecimal("1.0000"))
.setParameter("id", id)
.executeUpdate();
em.getTransaction().commit();
em.close();
DEMO.info("with handling-mode=allow, bulk HQL UPDATE rowsAffected={}", updated);
EntityManager verify = emf.createEntityManager();
ExchangeRate reloaded = verify.find(ExchangeRate.class, id);
DEMO.info("row after allowed bulk HQL update: {}", reloaded);
verify.close();
assertThat(updated).isEqualTo(1);
assertThat(reloaded.getRate()).isEqualByComparingTo("1.0000");
}
}
@@ -0,0 +1,330 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.immutable.AuditTrail;
import com.ankurm.hibernatedemo.immutable.ExchangeRate;
import com.ankurm.hibernatedemo.immutable.ExchangeRateVersioned;
import com.ankurm.hibernatedemo.immutable.PlainRate;
import com.ankurm.hibernatedemo.immutable.RateWithAuditTrail;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/07-immutable-entities.md ({@code @Immutable}, post 4866).
* Run with {@code ./mvnw -Dtest=ImmutableEntityTest test}.
*
* <p>Verified on Hibernate ORM 7.4.5.Final / jakarta.persistence-api 3.2.0 / H2 2.4.240.
*/
@SpringBootTest
class ImmutableEntityTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
/**
* THE headline claim: mutate a field on an @Immutable entity, flush inside a transaction --
* no UPDATE is issued, AND no exception is thrown. The silence is the story.
*/
@Test
void mutatingAndFlushing_immutableEntity_issuesNoUpdate_andThrowsNothing() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("USD/EUR", new BigDecimal("0.9200"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
stats().clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ExchangeRate managed = em.find(ExchangeRate.class, id);
managed.setRate(new BigDecimal("999.9999"));
DEMO.info("in-memory field mutated to {}, about to flush inside a transaction", managed.getRate());
em.getTransaction().commit(); // flush + commit; must not throw
em.close();
long updateCount = stats().getEntityUpdateCount();
DEMO.info("Statistics.getEntityUpdateCount() after mutate+flush = {}", updateCount);
assertThat(updateCount).isZero();
EntityManager verify = emf.createEntityManager();
ExchangeRate reloaded = verify.find(ExchangeRate.class, id);
DEMO.info("reloaded from DB: {}", reloaded);
assertThat(reloaded.getRate()).isEqualByComparingTo("0.9200");
verify.close();
}
/** @Immutable does not protect against EntityManager.remove() -- the DELETE goes through. */
@Test
void immutableEntity_canStillBeDeleted_viaEntityManagerRemove() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("GBP/USD", new BigDecimal("1.2500"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ExchangeRate managed = em.find(ExchangeRate.class, id);
em.remove(managed);
em.getTransaction().commit();
em.close();
EntityManager verify = emf.createEntityManager();
ExchangeRate gone = verify.find(ExchangeRate.class, id);
DEMO.info("after EntityManager.remove() on an @Immutable entity, find() returns: {}", gone);
assertThat(gone).isNull();
verify.close();
}
/**
* Does a bulk HQL "update ... set" statement respect @Immutable? By DEFAULT in 7.4.5,
* Hibernate refuses to even translate it -- it fails at query-compile time, before hitting
* the database, with an InterpretationException naming the offending entity and the exact
* setting that would allow it through.
*/
@Test
void bulkHqlUpdate_onImmutableEntity_isRejectedAtTranslationTime_byDefault() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("AUD/USD", new BigDecimal("0.6500"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Exception caught = null;
try {
em.createQuery("update ExchangeRate set rate = :r where id = :id")
.setParameter("r", new BigDecimal("42.0000"))
.setParameter("id", id)
.executeUpdate();
} catch (Exception e) {
caught = e;
}
em.getTransaction().rollback();
em.close();
DEMO.info("bulk HQL 'update ExchangeRate set ...' on an @Immutable entity threw: {}: {}",
caught == null ? "NOTHING" : caught.getClass().getName(),
caught == null ? "" : caught.getMessage());
assertThat(caught).isInstanceOf(org.hibernate.query.sqm.InterpretationException.class);
assertThat(caught.getMessage()).contains("attempts to update an immutable entity");
assertThat(caught.getMessage()).contains("immutable_entity_update_query_handling_mode");
}
/** Does a bulk HQL "delete" statement respect @Immutable? */
@Test
void bulkHqlDelete_onImmutableEntity_behaviour() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("NZD/USD", new BigDecimal("0.6100"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
int deleted = em.createQuery("delete from ExchangeRate where id = :id")
.setParameter("id", id)
.executeUpdate();
em.getTransaction().commit();
em.close();
DEMO.info("bulk HQL 'delete from ExchangeRate' executeUpdate() returned rowsAffected={}", deleted);
assertThat(deleted).isEqualTo(1);
EntityManager verify = emf.createEntityManager();
assertThat(verify.find(ExchangeRate.class, id)).isNull();
verify.close();
}
/** Native SQL is below Hibernate's tracking entirely -- @Immutable cannot see it. */
@Test
void nativeSqlUpdate_onImmutableEntity_alwaysWorks() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("CHF/USD", new BigDecimal("1.1000"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
int rows = em.createNativeQuery("update exchange_rate set rate = 7.7 where id = " + id)
.executeUpdate();
em.getTransaction().commit();
em.close();
DEMO.info("native SQL UPDATE rows={}", rows);
assertThat(rows).isEqualTo(1);
EntityManager verify = emf.createEntityManager();
ExchangeRate reloaded = verify.find(ExchangeRate.class, id);
DEMO.info("row after native SQL update: {}", reloaded);
assertThat(reloaded.getRate()).isEqualByComparingTo("7.7");
verify.close();
}
/**
* @Immutable on a @OneToMany collection: adding to it and flushing has historically thrown a
* specific exception. Capture the exact class and message in Hibernate 7.4.5.
*/
@Test
void addingToImmutableCollection_andFlushing_throwsSpecificException() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
RateWithAuditTrail parent = new RateWithAuditTrail("EUR/JPY");
seed.persist(parent);
seed.getTransaction().commit();
Long id = parent.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
RateWithAuditTrail managed = em.find(RateWithAuditTrail.class, id);
managed.getAuditTrails().add(new AuditTrail("added after load"));
Exception caught = null;
try {
em.getTransaction().commit();
} catch (Exception e) {
caught = e;
}
Throwable root = caught;
while (root != null && root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
DEMO.info("adding to an @Immutable collection and flushing threw: {}",
caught == null ? "NOTHING" : caught.getClass().getName() + ": " + caught.getMessage());
DEMO.info("root cause class: {} message: {}",
root == null ? "NONE" : root.getClass().getName(),
root == null ? "" : root.getMessage());
if (em.getTransaction().isActive()) {
em.getTransaction().rollback();
}
em.close();
assertThat(caught).isNotNull();
}
/** Does Hibernate accept @Immutable + @Version, and does the version column ever increment? */
@Test
void immutablePlusVersion_isAccepted_butVersionNeverIncrements() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRateVersioned rate = new ExchangeRateVersioned("SEK/USD", new BigDecimal("0.0950"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
Long initialVersion = rate.getVersion();
seed.close();
DEMO.info("persisted @Immutable+@Version entity, version after insert = {}", initialVersion);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ExchangeRateVersioned managed = em.find(ExchangeRateVersioned.class, id);
managed.setRate(new BigDecimal("0.1234"));
em.getTransaction().commit(); // must not throw
em.close();
EntityManager verify = emf.createEntityManager();
ExchangeRateVersioned reloaded = verify.find(ExchangeRateVersioned.class, id);
DEMO.info("after mutate+flush, version = {}, rate = {}", reloaded.getVersion(), reloaded.getRate());
verify.close();
assertThat(reloaded.getVersion()).isEqualTo(initialVersion);
assertThat(reloaded.getRate()).isEqualByComparingTo("0.0950");
}
/**
* Session.setReadOnly(entity, true) on a PLAIN (non-@Immutable) entity: same silent-no-UPDATE
* outcome as @Immutable, but it is a per-entity-instance runtime toggle rather than a
* class-wide, DDL-agnostic mapping annotation.
*/
@Test
void sessionSetReadOnlyOnPlainEntity_alsoSuppressesUpdate_perInstance() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
PlainRate p = new PlainRate("PLN/USD", new BigDecimal("4.0"));
seed.persist(p);
seed.getTransaction().commit();
Long id = p.getId();
seed.close();
stats().clear();
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
PlainRate managed = session.find(PlainRate.class, id);
session.setReadOnly(managed, true);
managed.setRate(new BigDecimal("999"));
em.getTransaction().commit();
em.close();
long updateCount = stats().getEntityUpdateCount();
DEMO.info("Session.setReadOnly(entity,true) then mutate+flush -> entityUpdateCount = {}", updateCount);
assertThat(updateCount).isZero();
EntityManager verify = emf.createEntityManager();
PlainRate reloaded = verify.find(PlainRate.class, id);
assertThat(reloaded.getRate()).isEqualByComparingTo("4.0");
verify.close();
}
/** Session.setDefaultReadOnly(true) applies the read-only flag to every entity loaded afterwards. */
@Test
void sessionSetDefaultReadOnly_appliesToAllSubsequentLoads() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
PlainRate p = new PlainRate("DKK/USD", new BigDecimal("6.9"));
seed.persist(p);
seed.getTransaction().commit();
Long id = p.getId();
seed.close();
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
session.setDefaultReadOnly(true);
em.getTransaction().begin();
PlainRate managed = session.find(PlainRate.class, id);
managed.setRate(new BigDecimal("111"));
em.getTransaction().commit();
em.close();
EntityManager verify = emf.createEntityManager();
PlainRate reloaded = verify.find(PlainRate.class, id);
DEMO.info("after setDefaultReadOnly(true) + mutate + flush, rate = {}", reloaded.getRate());
assertThat(reloaded.getRate()).isEqualByComparingTo("6.9");
verify.close();
}
}
@@ -0,0 +1,100 @@
package com.ankurm.hibernatedemo;
import com.ankurm.hibernatedemo.immutable.WideImmutableRow;
import com.ankurm.hibernatedemo.immutable.WideMutableRow;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/07-immutable-entities.md, "Does @Immutable actually cost less at flush time?".
*
* <p>This is NOT a rigorous benchmark: it is one Spring context, in a shared container, timed
* with {@code System.nanoTime()} around a single {@code flush()} call per entity type, no JMH,
* no warm-up isolation, no forked JVM. Treat the numbers as indicative of the right order of
* magnitude and direction, not as a citable throughput claim. Rows: 4000 per entity type,
* 12 String columns each, loaded fully into the persistence context, then flushed with NO
* pending changes -- so any time difference is purely Hibernate deciding "does this entity need
* an UPDATE", not the cost of writing one.
*/
@SpringBootTest
class ImmutableFlushCostTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 4000;
@Autowired
private EntityManagerFactory emf;
@Test
void flushingManyLoadedEntities_immutableSkipsDirtyCheck_mutableDoesNot() {
seedMutable(ROW_COUNT);
seedImmutable(ROW_COUNT);
// Warm-up pass (JIT, connection pool, buffer pool) -- discarded.
timeMutableFlush();
timeImmutableFlush();
long mutableNanos = timeMutableFlush();
long immutableNanos = timeImmutableFlush();
DEMO.info("flush() over {} loaded MUTABLE rows (12 cols, no pending changes): {} ms",
ROW_COUNT, mutableNanos / 1_000_000.0);
DEMO.info("flush() over {} loaded @Immutable rows (12 cols, no pending changes): {} ms",
ROW_COUNT, immutableNanos / 1_000_000.0);
DEMO.info("ratio (mutable / immutable) = {}", (double) mutableNanos / immutableNanos);
DEMO.info("CAVEAT: single-run, shared-container timing -- indicative only, not a benchmark result.");
}
private long timeMutableFlush() {
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
List<WideMutableRow> rows = session.createQuery("from WideMutableRow", WideMutableRow.class).list();
long start = System.nanoTime();
em.flush();
long elapsed = System.nanoTime() - start;
em.getTransaction().rollback();
em.close();
return elapsed;
}
private long timeImmutableFlush() {
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
List<WideImmutableRow> rows = session.createQuery("from WideImmutableRow", WideImmutableRow.class).list();
long start = System.nanoTime();
em.flush();
long elapsed = System.nanoTime() - start;
em.getTransaction().rollback();
em.close();
return elapsed;
}
private void seedMutable(int count) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 0; i < count; i++) {
em.persist(new WideMutableRow("seed-" + i));
}
em.getTransaction().commit();
em.close();
}
private void seedImmutable(int count) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 0; i < count; i++) {
em.persist(new WideImmutableRow("seed-" + i));
}
em.getTransaction().commit();
em.close();
}
}
@@ -0,0 +1,160 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.Book;
import com.ankurm.hibernatedemo.model.Note;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Hibernate;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4860. Docs: docs/02-merge-vs-refresh.md.
* Run with {@code ./mvnw -Dtest=MergeRefreshTest test}.
*
* <p>The optimistic-lock-conflict case lives in {@link OptimisticLockTest} instead, since it
* needs its own careful narration of exactly when the check fires.
*/
@SpringBootTest
class MergeRefreshTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedBook(String title, String status) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book(title, "Test Author");
book.setStatus(status);
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
@Test
void mergeOfDetachedInstance_returnsTheSameManagedInstanceAlreadyInSession() {
Long id = seedBook("Managed + Detached", "DRAFT");
// A separate, already-detached copy of the same row (simulates "the object a controller
// method was handed earlier").
EntityManager scratch = emf.createEntityManager();
scratch.getTransaction().begin();
Book detached = scratch.unwrap(Session.class).get(Book.class, id);
scratch.getTransaction().commit();
scratch.close();
detached.setAuthor("Changed On The Detached Copy");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
// This session already has ITS OWN managed instance for the same row before merge() is
// ever called.
Book managed = session.get(Book.class, id);
Book result = session.merge(detached);
assertThat(result)
.as("merge() must return the identity-equal MANAGED instance already tracked by this session, not a new object")
.isSameAs(managed);
assertThat(result).isNotSameAs(detached);
assertThat(managed.getAuthor())
.as("the pre-existing managed instance is the one that actually receives the copied state")
.isEqualTo("Changed On The Detached Copy");
em.getTransaction().commit();
em.close();
}
@Test
void mergeWithCascadeInitializesTheLazyCollectionAnyway() {
Long id;
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
Book book = new Book("Lazy Collection Book", "Test Author");
seed.persist(book);
seed.flush();
seed.persist(new Note("first note", book));
seed.getTransaction().commit();
id = book.getId();
seed.close();
// Load and detach WITHOUT ever touching book.getNotes() -- the collection proxy is never
// initialized.
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Book detached = em1.unwrap(Session.class).get(Book.class, id);
assertThat(Hibernate.isInitialized(detached.getNotes()))
.as("sanity check: the collection must still be uninitialized going into detachment")
.isFalse();
em1.getTransaction().commit();
em1.close();
detached.setAuthor("Edited While Detached");
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Session session = em2.unwrap(Session.class);
// Going in, the expectation was "merge() doesn't need to touch a collection it was
// never asked to load." That's true ONLY when the collection has no CascadeType.MERGE.
// Book.notes DOES cascade MERGE (see its Javadoc), and the measured result is the
// opposite of the naive expectation: merge() initializes the collection anyway, because
// cascading the merge to each element requires knowing what those elements are. Removing
// cascade = CascadeType.MERGE from Book.notes and re-running this test flips the result
// back to "stays uninitialized" -- confirmed with a throwaway probe before writing this
// assertion. See docs/02-merge-vs-refresh.md.
Book merged = session.merge(detached);
assertThat(Hibernate.isInitialized(merged.getNotes()))
.as("merge() DOES initialize a LAZY collection when it cascades MERGE to it -- cascading requires traversal")
.isTrue();
assertThat(merged.getAuthor()).isEqualTo("Edited While Detached");
em2.getTransaction().commit();
em2.close();
DEMO.info("merge() initialized the LAZY notes collection because CascadeType.MERGE forces traversal of it");
}
@Test
void refreshDiscardsUnflushedEditSilently_noExceptionEver() {
Long id = seedBook("Silent Overwrite", "USER_EDIT");
// Simulate an admin process changing the row out from under the in-memory object.
EntityManager admin = emf.createEntityManager();
admin.getTransaction().begin();
Book row = admin.unwrap(Session.class).get(Book.class, id);
row.setStatus("ADMIN_EDIT");
admin.getTransaction().commit();
admin.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book managed = session.get(Book.class, id);
assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT");
// A local, unflushed edit -- never sent to the database.
managed.setStatus("USER_EDIT");
assertThat(managed.getStatus()).isEqualTo("USER_EDIT");
session.refresh(managed);
assertThat(managed.getStatus())
.as("refresh() replaces managed state with the database row -- it does not merge the two; the local edit is simply gone, no exception")
.isEqualTo("ADMIN_EDIT");
em.getTransaction().commit();
em.close();
}
}
@@ -0,0 +1,100 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.OptimisticLockException;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4860. Docs: docs/02-merge-vs-refresh.md.
* Run with {@code ./mvnw -Dtest=OptimisticLockTest test}.
*
* <p>The specific thing this test pins down: exactly WHEN the version check fails. It would be
* easy to write "merge() throws OptimisticLockException" and leave it there; what actually
* happens depends on when Hibernate performs the check relative to the {@code merge()} call, the
* flush, and the commit -- and that's worth being precise about rather than assumed.
*/
@SpringBootTest
class OptimisticLockTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void mergeOfStaleVersionedEntity_throwsOptimisticLockException_andPinsDownWhen() {
// Seed.
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
Book book = new Book("Clean Code", "Robert C. Martin");
seed.persist(book);
seed.getTransaction().commit();
Long id = book.getId();
seed.close();
// Detach at version 0.
EntityManager loadEm = emf.createEntityManager();
loadEm.getTransaction().begin();
Book detached = loadEm.unwrap(Session.class).get(Book.class, id);
loadEm.getTransaction().commit();
loadEm.close();
assertThat(detached.getVersion()).isEqualTo(0L);
// A second, independent transaction advances the row to version 1.
EntityManager writer = emf.createEntityManager();
writer.getTransaction().begin();
Book row = writer.unwrap(Session.class).get(Book.class, id);
row.setTitle("Clean Code (2nd Edition)");
writer.getTransaction().commit();
writer.close();
// Mutate the STILL version-0 detached instance and attempt to merge it.
detached.setAuthor("Robert C. Martin (Uncle Bob)");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
boolean threwDuringMergeCallItself;
OptimisticLockException caught = null;
try {
session.merge(detached);
threwDuringMergeCallItself = false;
} catch (OptimisticLockException e) {
threwDuringMergeCallItself = true;
caught = e;
}
if (!threwDuringMergeCallItself) {
// merge() itself only queued the state transfer; the version check happens at flush.
caught = org.junit.jupiter.api.Assertions.assertThrows(
OptimisticLockException.class, () -> em.getTransaction().commit());
DEMO.info("OptimisticLockException surfaced at commit()/flush time, NOT from the merge() call itself.");
} else {
DEMO.info("OptimisticLockException surfaced directly from the merge() call.");
em.getTransaction().rollback();
}
assertThat(caught).isNotNull();
DEMO.info("exception: {}: {}", caught.getClass().getName(), caught.getMessage());
em.close();
// The other transaction's title change must have survived untouched.
EntityManager verify = emf.createEntityManager();
verify.getTransaction().begin();
Book current = verify.unwrap(Session.class).get(Book.class, id);
assertThat(current.getTitle()).isEqualTo("Clean Code (2nd Edition)");
assertThat(current.getAuthor()).isEqualTo("Robert C. Martin");
verify.getTransaction().commit();
verify.close();
}
}
@@ -0,0 +1,51 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetSequence;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=SequenceBatchTest test}.
*
* <p>{@link WidgetSequence} sets {@code allocationSize = 25}, matching
* {@code hibernate.jdbc.batch_size} in application.yml. See {@code AllocationSizeSweepTest} for
* what happens when the two are deliberately mismatched.
*/
@SpringBootTest
class SequenceBatchTest {
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
@Test
void sequenceGeneratorAllowsBatching_fourPreparedStatementsForThirtyRows() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetSequence("sequence-" + i));
}
em.getTransaction().commit();
em.close();
assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT);
assertThat(stats.getPrepareStatementCount())
.as("30 rows at batch_size=25 is 2 insert batches (25 + 5); allocationSize=25 "
+ "means the first 25 ids come from one sequence call and the remaining "
+ "5 force a second -- 2 insert batches + 2 sequence calls = 4")
.isEqualTo(4);
}
}
@@ -0,0 +1,156 @@
package com.ankurm.hibernatedemo.aggregate;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Root;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Proves the article's aggregate-function claims against a real H2 database: what an aggregate
* query returns over an empty table, {@code select new} record construction under GROUP BY /
* HAVING, the Criteria API equivalent, and HQL's {@code row_number()} window function.
*
* <p>Docs: docs/21-aggregate-functions.md.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class AggregateFunctionsTest {
@Autowired
private EntityManagerFactory emf;
@Test
void emptyResultSet_countIsZero_sumAndAvgAreNull_neitherThrows() {
// Scoped to a category that matches zero rows -- rather than relying on the whole table
// being empty, which would make this test's outcome depend on execution order against
// the other test methods in this class that share the same EntityManagerFactory/database.
String noSuchCategory = "no-such-category-zzz";
EntityManager em = emf.createEntityManager();
Long count = em.createQuery(
"select count(p) from Product p where p.category = :c", Long.class)
.setParameter("c", noSuchCategory)
.getSingleResult();
Double sum = em.createQuery(
"select sum(p.price) from Product p where p.category = :c", Double.class)
.setParameter("c", noSuchCategory)
.getSingleResult();
Double avg = em.createQuery(
"select avg(p.price) from Product p where p.category = :c", Double.class)
.setParameter("c", noSuchCategory)
.getSingleResult();
em.close();
System.out.println("RESULT[aggregate-empty-result-set]: over 0 matching rows -- "
+ "count(p)=" + count + " (never null) | sum(p.price)=" + sum + " | avg(p.price)="
+ avg + " -- getSingleResult() returned normally for all three, no "
+ "NoResultException, because SQL's aggregate functions over zero rows still "
+ "produce exactly one result row.");
assertThat(count).isZero();
assertThat(sum).isNull();
assertThat(avg).isNull();
}
@Test
void groupByHaving_selectNewRecord_producesTypedSummaries() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new Product("Keyboards", "Compact 65%", 89.0, 40));
em.persist(new Product("Keyboards", "Full-size", 59.0, 15));
em.persist(new Product("Keyboards", "Ergonomic split", 149.0, 5));
em.persist(new Product("Monitors", "27in 4K", 399.0, 8));
em.getTransaction().commit();
// Scoped to this test's own two categories -- the entity manager factory's H2 database is
// shared across every test method in this class, so an unscoped query here would also
// pick up Cables/Mice from the other test methods and make this assertion order-dependent.
List<CategorySummary> summaries = em.createQuery(
"select new com.ankurm.hibernatedemo.aggregate.CategorySummary("
+ "p.category, count(p), avg(p.price)) "
+ "from Product p where p.category in ('Keyboards', 'Monitors') "
+ "group by p.category having count(p) > 1 "
+ "order by p.category",
CategorySummary.class).getResultList();
em.close();
String rendered = summaries.stream()
.map(s -> s.category() + "(count=" + s.productCount() + ", avg=" + s.averagePrice() + ")")
.reduce((a, b) -> a + ", " + b)
.orElse("<none>");
System.out.println("RESULT[aggregate-groupby-having-record]: HAVING count(p) > 1 kept only "
+ "categories with more than one product -- " + rendered
+ " -- Monitors (1 product) was correctly excluded by HAVING, not just by GROUP BY.");
assertThat(summaries).hasSize(1);
assertThat(summaries.get(0).category()).isEqualTo("Keyboards");
assertThat(summaries.get(0).productCount()).isEqualTo(3);
}
@Test
void criteriaApi_avgWithGroupBy_matchesHqlEquivalent() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new Product("Cables", "USB-C 1m", 9.0, 200));
em.persist(new Product("Cables", "USB-C 2m", 13.0, 120));
em.getTransaction().commit();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);
Root<Product> root = cq.from(Product.class);
cq.multiselect(root.get("category"), cb.avg(root.get("price")))
.where(cb.equal(root.get("category"), "Cables"))
.groupBy(root.get("category"));
TypedQuery<Object[]> query = em.createQuery(cq);
Object[] row = query.getSingleResult();
em.close();
System.out.println("RESULT[aggregate-criteria-avg]: Criteria API cb.avg(root.get(\"price\")) "
+ "for category='" + row[0] + "' -- average=" + row[1]
+ " -- same numeric result as the equivalent HQL avg(p.price), just built without a"
+ " string query.");
assertThat((Double) row[1]).isEqualTo(11.0);
}
@Test
void windowFunction_rowNumberOverPartitionByCategory() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new Product("Mice", "Wireless A", 49.0, 30));
em.persist(new Product("Mice", "Wireless B", 79.0, 12));
em.persist(new Product("Mice", "Wired C", 19.0, 60));
em.getTransaction().commit();
List<Object[]> ranked = em.createQuery(
"select p.name, p.price, "
+ "row_number() over (partition by p.category order by p.price desc) "
+ "from Product p where p.category = 'Mice' order by p.price desc",
Object[].class).getResultList();
em.close();
StringBuilder rendered = new StringBuilder();
for (Object[] row : ranked) {
rendered.append(row[0]).append("=rank").append(row[2]).append(" ");
}
System.out.println("RESULT[aggregate-window-row-number]: row_number() over "
+ "(partition by category order by price desc) for the Mice category -- "
+ rendered.toString().trim()
+ " -- HQL's window-function support (the OVER clause), present since Hibernate "
+ "6.2 and still current in 7.4.5.Final, not a Hibernate-7-only feature.");
assertThat(ranked).hasSize(3);
assertThat(((Number) ranked.get(0)[2]).intValue()).isEqualTo(1);
assertThat(ranked.get(0)[0]).isEqualTo("Wireless B");
}
}
@@ -0,0 +1,181 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4872, docs/12-association-mappings.md chapters "MultipleBagFetchException"
* and "The cartesian-product trap".
*/
@SpringBootTest
class BagFetchTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM BagBookL").executeUpdate();
em.createQuery("DELETE FROM BagAwardL").executeUpdate();
em.createQuery("DELETE FROM BagAuthorList").executeUpdate();
em.createQuery("DELETE FROM BagBookS").executeUpdate();
em.createQuery("DELETE FROM BagAwardS").executeUpdate();
em.createQuery("DELETE FROM BagAuthorSet").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
private Long seedListAuthor(int nBooks, int nAwards) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BagAuthorList author = new BagAuthorList("List Author");
em.persist(author);
for (int i = 0; i < nBooks; i++) {
em.persist(new BagBookL("Book" + i, author));
}
for (int i = 0; i < nAwards; i++) {
em.persist(new BagAwardL("Award" + i, author));
}
em.getTransaction().commit();
Long id = author.getId();
em.close();
return id;
}
private Long seedSetAuthor(int nBooks, int nAwards) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BagAuthorSet author = new BagAuthorSet("Set Author");
em.persist(author);
for (int i = 0; i < nBooks; i++) {
em.persist(new BagBookS("Book" + i, author));
}
for (int i = 0; i < nAwards; i++) {
em.persist(new BagAwardS("Award" + i, author));
}
em.getTransaction().commit();
Long id = author.getId();
em.close();
return id;
}
@Test
void fetchJoiningTwoListsInOneQuery_throwsMultipleBagFetchException() {
seedListAuthor(4, 3);
EntityManager em = emf.createEntityManager();
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () ->
em.createQuery(
"SELECT a FROM BagAuthorList a JOIN FETCH a.books JOIN FETCH a.awards",
BagAuthorList.class).getResultList());
Throwable root = ex;
while (root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
DEMO.info("MultipleBagFetchException reproduction -- wrapper class: {}", ex.getClass().getName());
DEMO.info("MultipleBagFetchException reproduction -- root cause class: {}", root.getClass().getName());
DEMO.info("MultipleBagFetchException reproduction -- verbatim message: {}", root.getMessage());
assertThat(ex.getClass().getName()).as("EntityManager.createQuery(...).getResultList() wraps it as IllegalArgumentException, NOT PersistenceException")
.isEqualTo("java.lang.IllegalArgumentException");
assertThat(root.getClass().getName()).isEqualTo("org.hibernate.loader.MultipleBagFetchException");
em.close();
}
@Test
void fix1_useSetsInsteadOfLists_noExceptionOneQuery() {
seedSetAuthor(4, 3);
EntityManager em = emf.createEntityManager();
stats().clear();
List<BagAuthorSet> authors = em.createQuery(
"SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards",
BagAuthorSet.class)
.getResultList();
long queries = stats().getPrepareStatementCount();
DEMO.info("Fix #1 (Set instead of List): {} distinct authors returned, {} queries fired", authors.size(), queries);
assertThat(authors).hasSize(1);
assertThat(authors.get(0).getBooks()).hasSize(4);
assertThat(authors.get(0).getAwards()).hasSize(3);
assertThat(queries).isEqualTo(1);
em.close();
}
@Test
void fix2_twoSeparateQueries_avoidsBagFetchExceptionEntirely() {
Long id = seedListAuthor(4, 3);
EntityManager em = emf.createEntityManager();
stats().clear();
BagAuthorList author = em.createQuery(
"SELECT a FROM BagAuthorList a JOIN FETCH a.books WHERE a.id = :id", BagAuthorList.class)
.setParameter("id", id)
.getSingleResult();
// second, separate query for the other bag -- no exception because only one JOIN FETCH per query
author = em.createQuery(
"SELECT a FROM BagAuthorList a JOIN FETCH a.awards WHERE a.id = :id", BagAuthorList.class)
.setParameter("id", id)
.getSingleResult();
long queries = stats().getPrepareStatementCount();
DEMO.info("Fix #2 (two queries): {} queries fired, books={}, awards={}",
queries, author.getBooks().size(), author.getAwards().size());
assertThat(author.getBooks()).hasSize(4);
assertThat(author.getAwards()).hasSize(3);
assertThat(queries).as("one JOIN FETCH per query, run twice").isEqualTo(2);
em.close();
}
@Test
void cartesianProduct_fetchJoiningTwoAllowedSetsExplodesRowCount() {
seedSetAuthor(4, 3);
EntityManager em = emf.createEntityManager();
// Raw SQL join without DISTINCT to see the true row count the database returns.
Object rawCount = em.createNativeQuery(
"SELECT COUNT(*) FROM bag_author_set a " +
"JOIN bag_book_s b ON b.author_id = a.id " +
"JOIN bag_award_s w ON w.author_id = a.id")
.getSingleResult();
long rawRowCount = ((Number) rawCount).longValue();
List<BagAuthorSet> entities = em.createQuery(
"SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards",
BagAuthorSet.class)
.getResultList();
DEMO.info("Cartesian product: 4 books x 3 awards for 1 author -> raw SQL join rows = {}, distinct entities returned = {}",
rawRowCount, entities.size());
assertThat(rawRowCount).as("SQL returns one row per (book, award) pair: 4 x 3").isEqualTo(12);
assertThat(entities).as("Hibernate's DISTINCT root-entity de-duplication collapses this back to 1 entity")
.hasSize(1);
assertThat(entities.get(0).getBooks()).hasSize(4);
assertThat(entities.get(0).getAwards()).hasSize(3);
em.close();
}
}
@@ -0,0 +1,188 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4872, docs/12-association-mappings.md chapters "Cascade and orphanRemoval"
* and "The owning side".
*/
@SpringBootTest
class CascadeOrphanTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM CascadeBook").executeUpdate();
em.createQuery("DELETE FROM CascadeAuthor").executeUpdate();
em.createQuery("DELETE FROM NoOrphanBook").executeUpdate();
em.createQuery("DELETE FROM NoOrphanAuthor").executeUpdate();
em.getTransaction().commit();
em.close();
}
@Test
void cascadeAllPlusOrphanRemoval_reassigningTheCollectionThrowsInsteadOfSilentlyDeleting() {
// CORRECTION vs the common blog claim ("silently deletes what you did not expect"):
// assigning a brand new List instance to a CascadeType.ALL + orphanRemoval=true
// collection does NOT silently delete anything -- Hibernate detects the managed
// collection was dereferenced and throws at flush/commit time instead.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
CascadeAuthor author = new CascadeAuthor("Prolific Author");
em.persist(author);
em.persist(new CascadeBook("Book A", author));
em.persist(new CascadeBook("Book B", author));
em.getTransaction().commit();
Long id = author.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
CascadeAuthor managed = em2.find(CascadeAuthor.class, id);
CascadeBook keep = managed.getBooks().get(0);
managed.replaceBooksWithNewList(new ArrayList<>(List.of(keep))); // assigns a NEW ArrayList
jakarta.persistence.RollbackException ex = org.junit.jupiter.api.Assertions.assertThrows(
jakarta.persistence.RollbackException.class, () -> em2.getTransaction().commit());
Throwable root = ex.getCause();
DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- wrapper: {}", ex.getClass().getName());
DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- root cause: {}: {}",
root.getClass().getName(), root.getMessage());
em2.close();
assertThat(root.getClass().getName()).isEqualTo("org.hibernate.HibernateException");
assertThat(root.getMessage()).contains("no longer referenced by the owning entity instance");
}
@Test
void cascadeAllPlusOrphanRemoval_mutatingTheExistingCollectionDoesDeleteUnexpectedly() {
// The scenario that DOES silently delete: mutating the SAME managed collection instance
// (clear() then re-add the ones to keep) rather than reassigning a new List. This is
// the realistic version of the "developer did not expect this" cascade trap.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
CascadeAuthor author = new CascadeAuthor("Prolific Author 2");
em.persist(author);
em.persist(new CascadeBook("Book A", author));
em.persist(new CascadeBook("Book B", author));
em.persist(new CascadeBook("Book C", author));
em.getTransaction().commit();
Long id = author.getId();
em.close();
EntityManager check1 = emf.createEntityManager();
long before = ((Number) check1.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id")
.setParameter("id", id).getSingleResult()).longValue();
check1.close();
assertThat(before).isEqualTo(3);
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
CascadeAuthor managed = em2.find(CascadeAuthor.class, id);
CascadeBook keep = managed.getBooks().stream().filter(b -> b.getTitle().equals("Book A")).findFirst().orElseThrow();
// Mutate the EXISTING Hibernate-managed collection in place -- this is the pattern that
// actually reaches production: "keep only Book A" implemented via removeIf.
managed.getBooks().removeIf(b -> !b.getTitle().equals("Book A"));
em2.getTransaction().commit();
em2.close();
EntityManager check2 = emf.createEntityManager();
long after = ((Number) check2.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id")
.setParameter("id", id).getSingleResult()).longValue();
DEMO.info("cascade=ALL + orphanRemoval=true, in-place removeIf(): books before={}, books after={}", before, after);
check2.close();
assertThat(after)
.as("orphanRemoval=true DELETEd Book B and Book C as soon as they left the managed collection")
.isEqualTo(1);
}
@Test
void noOrphanRemoval_removingFromCollectionAndFlushingDoesNothingToTheRow() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NoOrphanAuthor author = new NoOrphanAuthor("Careful Author");
em.persist(author);
NoOrphanBook book1 = new NoOrphanBook("Keep Me", author);
NoOrphanBook book2 = new NoOrphanBook("Remove Me From Collection", author);
em.persist(book1);
em.persist(book2);
em.getTransaction().commit();
Long authorId = author.getId();
Long book2Id = book2.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
NoOrphanAuthor managed = em2.find(NoOrphanAuthor.class, authorId);
managed.getBooks().removeIf(b -> b.getId().equals(book2Id));
em2.getTransaction().commit(); // no orphanRemoval, no cascade REMOVE -- nothing should happen to book2's row
em2.close();
EntityManager check = emf.createEntityManager();
NoOrphanBook stillThere = check.find(NoOrphanBook.class, book2Id);
DEMO.info("orphanRemoval=false: after removing book2 from author.books and flushing, book2 row still exists = {}, author_id still = {}",
stillThere != null, stillThere == null ? null : stillThere.getAuthor().getId());
check.close();
assertThat(stillThere)
.as("without orphanRemoval or cascade REMOVE, removing a child from the collection does not delete or unlink its row")
.isNotNull();
assertThat(stillThere.getAuthor().getId())
.as("its FK is untouched -- still points at the same author")
.isEqualTo(authorId);
}
@Test
void owningSide_mutatingOnlyTheInverseCollectionNeverWritesTheForeignKey() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NoOrphanAuthor author1 = new NoOrphanAuthor("Author One");
NoOrphanAuthor author2 = new NoOrphanAuthor("Author Two");
em.persist(author1);
em.persist(author2);
NoOrphanBook orphanBook = new NoOrphanBook("Unassigned Book", null);
em.persist(orphanBook);
em.getTransaction().commit();
Long bookId = orphanBook.getId();
Long author2Id = author2.getId();
em.close();
// Mutate ONLY the inverse (mappedBy) side: add the book to author2's collection, but
// never call book.setAuthor(author2) -- the collection is not the owning side.
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
NoOrphanAuthor managedAuthor2 = em2.find(NoOrphanAuthor.class, author2Id);
NoOrphanBook managedBook = em2.find(NoOrphanBook.class, bookId);
managedAuthor2.getBooks().add(managedBook); // inverse side only
em2.getTransaction().commit();
em2.close();
EntityManager check = emf.createEntityManager();
NoOrphanBook reloaded = check.find(NoOrphanBook.class, bookId);
DEMO.info("owning side test: mutated only author2.getBooks().add(book) (inverse side), book.author after flush = {}",
reloaded.getAuthor() == null ? "null (FK not written)" : reloaded.getAuthor().getId());
check.close();
assertThat(reloaded.getAuthor())
.as("mutating only the inverse (mappedBy) collection never persists the association -- the owning side (Book.author) decides the FK")
.isNull();
}
}
@@ -0,0 +1,218 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.TypedQuery;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/**
* Backs ankurm.com post 4872, docs/12-association-mappings.md chapter "Counting the N+1".
*
* <p>Seeds 100 authors with 3 books each (301 rows total incl. authors) and counts real
* {@code Statistics.getPrepareStatementCount()} for four strategies: naive lazy iteration,
* JPQL fetch join, {@code @EntityGraph}, and {@code @BatchSize(10)}. Run with
* {@code mvn -Dtest=NPlusOneTest test}.
*/
@SpringBootTest
class NPlusOneTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int AUTHOR_COUNT = 100;
private static final int BOOKS_PER_AUTHOR = 3;
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM AssocBook").executeUpdate();
em.createQuery("DELETE FROM AssocAuthor").executeUpdate();
em.createQuery("DELETE FROM BatchBook").executeUpdate();
em.createQuery("DELETE FROM BatchAuthor").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
private void seedPlainAuthors() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int a = 0; a < AUTHOR_COUNT; a++) {
AssocAuthor author = new AssocAuthor("Author-" + a);
em.persist(author);
for (int b = 0; b < BOOKS_PER_AUTHOR; b++) {
em.persist(new AssocBook("Book-" + a + "-" + b, author));
}
}
em.getTransaction().commit();
em.close();
}
private void seedBatchAuthors() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int a = 0; a < AUTHOR_COUNT; a++) {
BatchAuthor author = new BatchAuthor("BatchAuthor-" + a);
em.persist(author);
for (int b = 0; b < BOOKS_PER_AUTHOR; b++) {
em.persist(new BatchBook("BatchBook-" + a + "-" + b, author));
}
}
em.getTransaction().commit();
em.close();
}
@Test
void naiveLazyIteration_firesOneQueryPerAuthor_theClassicNPlusOne() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<AssocAuthor> authors = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size(); // triggers one SELECT per author
}
long queries = stats().getPrepareStatementCount();
DEMO.info("NAIVE lazy iteration: {} authors, {} queries (1 for authors + {} for their book collections), books touched = {}",
authors.size(), queries, authors.size(), booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries)
.as("1 query to load authors + 1 per author to lazily load its books == N+1")
.isEqualTo(1 + AUTHOR_COUNT);
em.close();
}
@Test
void jpqlFetchJoin_firesExactlyOneQuery() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<AssocAuthor> authors = em.createQuery(
"SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class)
.getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size(); // already initialised, no extra SELECT
}
long queries = stats().getPrepareStatementCount();
DEMO.info("JPQL JOIN FETCH: {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("fetch join collapses N+1 into a single query").isEqualTo(1);
em.close();
}
@Test
void entityGraph_firesExactlyOneQuery() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
EntityGraph<?> graph = em.getEntityGraph("AssocAuthor.books");
TypedQuery<AssocAuthor> query = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class);
query.setHint("jakarta.persistence.fetchgraph", graph);
List<AssocAuthor> authors = query.getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size();
}
long queries = stats().getPrepareStatementCount();
DEMO.info("@EntityGraph (fetchgraph hint): {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("entity graph fetch also collapses to a single query").isEqualTo(1);
em.close();
}
@Test
void batchSize10_collapsesNPlusOneIntoCeilNOverBatchSizePlusOne() {
seedBatchAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<BatchAuthor> authors = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList();
long booksTouched = 0;
for (BatchAuthor author : authors) {
booksTouched += author.getBooks().size(); // triggers 1 SELECT per 10 authors (batch fetch)
}
long queries = stats().getPrepareStatementCount();
// ceil(100 / 10) = 10 batch selects + 1 for the authors themselves = 11
long expected = 1 + (long) Math.ceil(AUTHOR_COUNT / 10.0);
DEMO.info("@BatchSize(10): {} authors, {} queries (expected {} = 1 + ceil({}/10)), books touched = {}",
authors.size(), queries, expected, AUTHOR_COUNT, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("BatchSize(10) turns 101 queries into ceil(100/10)+1 = 11").isEqualTo(expected);
em.close();
}
@Test
void printSideBySideComparisonTable() {
// Re-derive each number in one place so the article table and the assertions above can
// never silently drift apart.
seedPlainAuthors();
seedBatchAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<AssocAuthor> naive = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList();
naive.forEach(a -> a.getBooks().size());
long naiveQ = stats().getPrepareStatementCount();
stats().clear();
List<AssocAuthor> fetchJoin = em.createQuery(
"SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class)
.getResultList();
fetchJoin.forEach(a -> a.getBooks().size());
long fetchJoinQ = stats().getPrepareStatementCount();
stats().clear();
EntityGraph<?> graph = em.getEntityGraph("AssocAuthor.books");
TypedQuery<AssocAuthor> egQuery = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class);
egQuery.setHint("jakarta.persistence.fetchgraph", graph);
List<AssocAuthor> eg = egQuery.getResultList();
eg.forEach(a -> a.getBooks().size());
long egQ = stats().getPrepareStatementCount();
stats().clear();
List<BatchAuthor> batched = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList();
batched.forEach(a -> a.getBooks().size());
long batchQ = stats().getPrepareStatementCount();
DEMO.info("=== Side-by-side query counts for {} authors x {} books each ===", AUTHOR_COUNT, BOOKS_PER_AUTHOR);
DEMO.info("naive lazy iteration : {} queries", naiveQ);
DEMO.info("JPQL JOIN FETCH : {} queries", fetchJoinQ);
DEMO.info("@EntityGraph : {} queries", egQ);
DEMO.info("@BatchSize(10) : {} queries", batchQ);
assertThat(Map.of("naive", naiveQ, "fetchJoin", fetchJoinQ, "entityGraph", egQ, "batch10", batchQ))
.isEqualTo(Map.of("naive", 101L, "fetchJoin", 1L, "entityGraph", 1L, "batch10", 11L));
em.close();
}
}
@@ -0,0 +1,115 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4872, docs/12-association-mappings.md chapter "The @OneToOne lazy trap".
*
* <p>{@link LazyUser#getProfile()} is the non-owning ("mappedBy") side of an optional
* {@code @OneToOne}, declared {@code FetchType.LAZY}. Without bytecode enhancement, Hibernate
* cannot build a lazy proxy for it (it has no FK column to defer against), so it fires an
* eager extra SELECT regardless of the annotation. {@link MiUser}/{@link MiProfile} show the
* {@code @MapsId} fix: don't map the inverse side at all, look the child up on demand by the
* shared primary key.
*/
@SpringBootTest
class OneToOneLazyTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM LazyProfile").executeUpdate();
em.createQuery("DELETE FROM LazyUser").executeUpdate();
em.createQuery("DELETE FROM MiProfile").executeUpdate();
em.createQuery("DELETE FROM MiUser").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
@Test
void mappedBySideOneToOne_firesExtraSelectEvenThoughItIsDeclaredLazy() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
LazyUser user = new LazyUser("alice");
em.persist(user);
LazyProfile profile = new LazyProfile("bio text", user);
user.setProfile(profile);
em.persist(profile);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
stats().clear();
LazyUser loaded = em2.find(LazyUser.class, id);
long queriesAfterFind = stats().getPrepareStatementCount();
DEMO.info("LazyUser.find(): {} queries fired BEFORE touching getProfile() at all (expected 2: user + eager profile join/select)", queriesAfterFind);
// Note: the extra SELECT for profile already happened during find(), not here.
String bio = loaded.getProfile() == null ? null : "loaded";
long queriesAfterAccess = stats().getPrepareStatementCount();
DEMO.info("after touching getProfile(): {} queries total (profile={})", queriesAfterAccess, bio);
assertThat(queriesAfterFind)
.as("mappedBy @OneToOne(LAZY) still fires the profile SELECT immediately during find(), not on first access")
.isEqualTo(2);
assertThat(queriesAfterAccess).as("no further query needed -- it already ran eagerly").isEqualTo(2);
em2.close();
}
@Test
void mapsIdFix_loadingUserAloneFiresOnlyOneQuery_profileFetchedOnDemand() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
MiUser user = new MiUser("bob");
em.persist(user);
em.flush(); // need the generated id before building the shared-PK child
MiProfile profile = new MiProfile("bob's bio", user);
em.persist(profile);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
stats().clear();
MiUser loaded = em2.find(MiUser.class, id);
long queriesForUserOnly = stats().getPrepareStatementCount();
DEMO.info("MiUser.find() (no mappedBy field at all): {} query", queriesForUserOnly);
// Fetch profile ONLY when actually needed, using the shared PK -- no proxy required.
MiProfile profileOnDemand = em2.find(MiProfile.class, id);
long queriesAfterExplicitProfileFetch = stats().getPrepareStatementCount();
DEMO.info("explicit MiProfile.find() by shared PK when actually needed: {} total queries", queriesAfterExplicitProfileFetch);
assertThat(loaded).isNotNull();
assertThat(queriesForUserOnly)
.as("with @MapsId and no inverse mappedBy field, loading the user alone costs exactly 1 query")
.isEqualTo(1);
assertThat(profileOnDemand.getId()).isEqualTo(id);
assertThat(queriesAfterExplicitProfileFetch)
.as("fetching the profile only happens when the code actually asks for it")
.isEqualTo(2);
em2.close();
}
}
@@ -0,0 +1,152 @@
package com.ankurm.hibernatedemo.bootstrap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import jakarta.persistence.PersistenceConfiguration;
import jakarta.persistence.PersistenceException;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs ankurm.com post 4855 (bootstrapping EntityManager). Docs: docs/17-entitymanager-bootstrap.md.
*
* <p>Deliberately plain JUnit, NOT {@code @SpringBootTest} -- the whole point of this chapter is
* bootstrapping a raw JPA {@link EntityManagerFactory} with no Spring involved, exactly as a
* Java SE application or a unit test outside a Spring context would. The XML path reads
* {@code src/test/resources/META-INF/persistence.xml}; the programmatic path uses Jakarta
* Persistence 3.2's {@link PersistenceConfiguration}, new in this version.
*
* <p>Run with {@code ./mvnw -Dtest=EntityManagerBootstrapTest test}.
*/
class EntityManagerBootstrapTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Test
void xmlBootstrap_createsFactoryFromPersistenceXmlAndPersistsAUser() {
// No properties map passed here at all -- everything comes from
// META-INF/persistence.xml on the test classpath, resolved purely by unit name.
try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("XmlBootstrapPU")) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BootstrapUser user = new BootstrapUser("Ankur", "[email protected]");
em.persist(user);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager reader = emf.createEntityManager();
BootstrapUser reloaded = reader.find(BootstrapUser.class, id);
assertThat(reloaded).isNotNull();
assertThat(reloaded.getEmail()).isEqualTo("[email protected]");
reader.close();
DEMO.info("xmlBootstrap: persisted and reloaded user id={}", id);
}
}
@Test
void programmaticBootstrap_needsNoPersistenceXmlUnitOnClasspath() {
// "ProgrammaticPU" has NO matching <persistence-unit> anywhere in persistence.xml --
// this only works at all if PersistenceConfiguration genuinely bypasses XML lookup.
PersistenceConfiguration config = new PersistenceConfiguration("ProgrammaticPU")
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
.managedClass(BootstrapUser.class)
.property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver")
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-programmatic;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.property("hibernate.hbm2ddl.auto", "create-drop");
try (EntityManagerFactory emf = config.createEntityManagerFactory()) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BootstrapUser user = new BootstrapUser("Programmatic", "[email protected]");
em.persist(user);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager reader = emf.createEntityManager();
assertThat(reader.find(BootstrapUser.class, id)).isNotNull();
reader.close();
DEMO.info("programmaticBootstrap: persisted user id={} with zero persistence.xml units named 'ProgrammaticPU'", id);
}
}
@Test
void persistenceUnitName_inProgrammaticConfig_doesNotTriggerXmlSearch() {
// Reusing "XmlBootstrapPU" -- the EXACT SAME name as the real XML unit -- but with
// completely different properties built purely in code, pointed at a third database.
// If the name alone triggered an XML lookup/merge, this factory would end up
// connected to bootstrap-xml (the XML unit's database) instead. It doesn't.
PersistenceConfiguration config = new PersistenceConfiguration("XmlBootstrapPU")
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
.managedClass(BootstrapUser.class)
.property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver")
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-namecollision;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.property("hibernate.hbm2ddl.auto", "create-drop");
try (EntityManagerFactory emf = config.createEntityManagerFactory()) {
EntityManager em = emf.createEntityManager();
@SuppressWarnings("unchecked")
java.util.List<Object> row = em.createNativeQuery("SELECT DATABASE()").getResultList();
String actualDb = (String) row.get(0);
assertThat(actualDb).as("the programmatic config's own JDBC URL won, not the XML unit's")
.isEqualToIgnoringCase("BOOTSTRAP-NAMECOLLISION");
em.close();
DEMO.info("persistenceUnitNameCollision: connected database = {} (unit name 'XmlBootstrapPU' reused on purpose)", actualDb);
}
}
@Test
void unconfiguredUnitName_failsWithPersistenceException_notSilently() {
// "TotallyUnknownPU" exists neither in persistence.xml nor as a PersistenceConfiguration
// -- Persistence.createEntityManagerFactory(name) with no config map has nowhere left
// to look.
assertThatThrownBy(() -> Persistence.createEntityManagerFactory("TotallyUnknownPU"))
.isInstanceOf(PersistenceException.class)
.satisfies(ex -> {
DEMO.info("unconfiguredUnitName: {}: {}", ex.getClass().getName(), ex.getMessage());
assertThat(ex.getMessage()).contains("TotallyUnknownPU");
});
}
@Test
void repeatedFactoryCreation_isMeasurablyExpensive() {
// Not a Metaspace OOM reproduction -- that needs sustained, uncollectable class-loader
// growth over many thousands of factories and isn't something to actually trigger in a
// shared CI sandbox. What IS safely measurable: EntityManagerFactory creation is not
// cheap, which is the whole reason "never create one per request" is the rule in the
// first place.
PersistenceConfiguration config = new PersistenceConfiguration("TimingPU")
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
.managedClass(BootstrapUser.class)
.property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver")
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-timing;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.property("hibernate.hbm2ddl.auto", "create-drop");
long factoryStart = System.nanoTime();
EntityManagerFactory emf = config.createEntityManagerFactory();
long factoryMillis = (System.nanoTime() - factoryStart) / 1_000_000;
long emStart = System.nanoTime();
EntityManager em = emf.createEntityManager();
long emMillis = (System.nanoTime() - emStart) / 1_000_000;
em.close();
emf.close();
assertThat(factoryMillis).as("factory creation happened and was timed").isGreaterThanOrEqualTo(0);
DEMO.info("repeatedFactoryCreation: createEntityManagerFactory() took {} ms, createEntityManager() took {} ms -- "
+ "the factory call is the one doing schema validation, service registry bootstrap, and metadata scanning; "
+ "the EntityManager call is comparatively trivial", factoryMillis, emMillis);
}
}
@@ -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();
}
}
@@ -0,0 +1,83 @@
package com.ankurm.hibernatedemo.datetime;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "Basic temporal types round trip".
* The generated DDL for {@code temporal_types} (visible in the captured log because
* {@code show_sql=true}) is the primary artifact here.
*/
@SpringBootTest
class BasicTemporalTypesTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void roundTripEveryBasicTemporalType() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
TemporalTypesEntity e = new TemporalTypesEntity();
e.setLocalDate(LocalDate.of(2026, 3, 15));
e.setLocalDateTime(LocalDateTime.of(2026, 3, 15, 10, 30, 45));
e.setLocalTime(LocalTime.of(10, 30, 45));
e.setInstant(Instant.parse("2026-03-15T10:30:45Z"));
e.setOffsetDateTime(OffsetDateTime.of(2026, 3, 15, 10, 30, 45, 0, ZoneOffset.ofHoursMinutes(5, 30)));
e.setZonedDateTime(ZonedDateTime.of(2026, 3, 15, 10, 30, 45, 0, java.time.ZoneId.of("Europe/Paris")));
Calendar cal = new GregorianCalendar(2026, Calendar.MARCH, 15, 10, 30, 45);
Date legacyDate = cal.getTime();
e.setLegacyDateAsDate(legacyDate);
e.setLegacyDateAsTimestamp(legacyDate);
e.setLegacyDateNoTemporal(legacyDate);
e.setLegacyCalendar(cal);
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
TemporalTypesEntity loaded = em2.find(TemporalTypesEntity.class, id);
DEMO.info("ROUNDTRIP localDate = {}", loaded.getLocalDate());
DEMO.info("ROUNDTRIP localDateTime = {}", loaded.getLocalDateTime());
DEMO.info("ROUNDTRIP localTime = {}", loaded.getLocalTime());
DEMO.info("ROUNDTRIP instant = {}", loaded.getInstant());
DEMO.info("ROUNDTRIP offsetDateTime = {}", loaded.getOffsetDateTime());
DEMO.info("ROUNDTRIP zonedDateTime = {}", loaded.getZonedDateTime());
DEMO.info("ROUNDTRIP legacyDateAsDate = {}", loaded.getLegacyDateAsDate());
DEMO.info("ROUNDTRIP legacyDateAsTimestamp = {}", loaded.getLegacyDateAsTimestamp());
DEMO.info("ROUNDTRIP legacyDateNoTemporal = {} (class={})", loaded.getLegacyDateNoTemporal(), loaded.getLegacyDateNoTemporal().getClass());
DEMO.info("ROUNDTRIP legacyCalendar = {}", loaded.getLegacyCalendar() == null ? null : loaded.getLegacyCalendar().getTime());
DEMO.info("JVM default timezone during this run = {}", TimeZone.getDefault().getID());
assertThat(loaded.getLocalDate()).isEqualTo(LocalDate.of(2026, 3, 15));
assertThat(loaded.getLocalTime()).isEqualTo(LocalTime.of(10, 30, 45));
assertThat(loaded.getInstant()).isEqualTo(Instant.parse("2026-03-15T10:30:45Z"));
assertThat(loaded.getZonedDateTime().toInstant()).isEqualTo(e.getZonedDateTime().toInstant());
em2.close();
}
}
@@ -0,0 +1,74 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "hibernate.jdbc.time_zone".
* Sets {@code hibernate.jdbc.time_zone=America/New_York} (JVM default left at whatever the
* build runs under) and observes: (1) what it does to a {@code LocalDateTime} (no offset of
* its own -- the JDBC time zone controls what wall-clock value is actually sent to the driver),
* and (2) whether it changes anything for a {@code TimeZoneStorage.NATIVE} OffsetDateTime,
* which already carries its own explicit offset.
*/
@SpringBootTest
@TestPropertySource(properties = {
"spring.jpa.properties.hibernate.jdbc.time_zone=America/New_York"
})
class JdbcTimeZoneTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void jdbcTimeZone_affectsLocalDateTime_doesNotAffectExplicitOffset() {
LocalDateTime localDateTime = LocalDateTime.of(2026, 7, 4, 9, 0, 0);
OffsetDateTime explicitOffset = OffsetDateTime.of(2026, 7, 4, 9, 0, 0, 0, ZoneOffset.ofHoursMinutes(5, 30));
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NanoPrecisionEntity np = new NanoPrecisionEntity();
np.setPlainLocalDateTime(localDateTime);
em.persist(np);
TimeZoneStorageEntity tz = new TimeZoneStorageEntity();
tz.setNativeMode(explicitOffset);
em.persist(tz);
em.getTransaction().commit();
Long npId = np.getId();
Long tzId = tz.getId();
em.close();
// Read back the raw stored value via native SQL to see the literal DB representation.
EntityManager em2 = emf.createEntityManager();
Object rawLocalDateTime = em2.createNativeQuery(
"SELECT CAST(plain_local_date_time AS VARCHAR) FROM nano_precision WHERE id = :id")
.setParameter("id", npId).getSingleResult();
Object rawOffset = em2.createNativeQuery(
"SELECT CAST(native_col AS VARCHAR) FROM tz_storage WHERE id = :id")
.setParameter("id", tzId).getSingleResult();
NanoPrecisionEntity npLoaded = em2.find(NanoPrecisionEntity.class, npId);
TimeZoneStorageEntity tzLoaded = em2.find(TimeZoneStorageEntity.class, tzId);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- original LocalDateTime = {}", localDateTime);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- raw DB value for LocalDateTime column = {}", rawLocalDateTime);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- round-tripped LocalDateTime = {}", npLoaded.getPlainLocalDateTime());
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- original OffsetDateTime (NATIVE) = {}", explicitOffset);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- raw DB value for NATIVE offset column = {}", rawOffset);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- round-tripped OffsetDateTime (NATIVE) = {}", tzLoaded.getNativeMode());
em2.close();
}
}
@@ -0,0 +1,62 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.Instant;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Same experiment as {@link NanosecondTruncationTest} but against HSQLDB 2.7.3 in-memory,
* to see whether the microsecond rounding is H2-specific or shared. Docs: 13-date-and-time-mapping.md.
*/
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:hsqldb:mem:nanotest;shutdown=true",
"spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver",
"spring.datasource.username=SA",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.HSQLDialect"
})
class NanosecondTruncationHsqldbTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void nanosecondsOnHsqldb() {
LocalDateTime withNanos = LocalDateTime.of(2026, 1, 1, 12, 0, 0, 123_456_789);
Instant instantWithNanos = Instant.ofEpochSecond(1_800_000_000L, 123_456_789);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NanoPrecisionEntity e = new NanoPrecisionEntity();
e.setPlainLocalDateTime(withNanos);
e.setHighPrecisionLocalDateTime(withNanos);
e.setPlainInstant(instantWithNanos);
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
NanoPrecisionEntity loaded = em2.find(NanoPrecisionEntity.class, id);
DEMO.info("HSQLDB 2.7.3: original LocalDateTime nanos = {}", withNanos.getNano());
DEMO.info("HSQLDB 2.7.3: plain column (precision default) nanos = {} (value={})",
loaded.getPlainLocalDateTime().getNano(), loaded.getPlainLocalDateTime());
DEMO.info("HSQLDB 2.7.3: @Column(precision=9) column nanos = {} (value={})",
loaded.getHighPrecisionLocalDateTime().getNano(), loaded.getHighPrecisionLocalDateTime());
DEMO.info("HSQLDB 2.7.3: original Instant nanos = {}", instantWithNanos.getNano());
DEMO.info("HSQLDB 2.7.3: plain Instant column nanos = {} (value={})",
loaded.getPlainInstant().getNano(), loaded.getPlainInstant());
em2.close();
}
}
@@ -0,0 +1,54 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.Instant;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "Second-precision / truncation".
* Default (H2) profile here; see {@link NanosecondTruncationHsqldbTest} for the HSQLDB variant.
*/
@SpringBootTest
class NanosecondTruncationTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void nanosecondsOnH2() {
LocalDateTime withNanos = LocalDateTime.of(2026, 1, 1, 12, 0, 0, 123_456_789);
Instant instantWithNanos = Instant.ofEpochSecond(1_800_000_000L, 123_456_789);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NanoPrecisionEntity e = new NanoPrecisionEntity();
e.setPlainLocalDateTime(withNanos);
e.setHighPrecisionLocalDateTime(withNanos);
e.setPlainInstant(instantWithNanos);
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
NanoPrecisionEntity loaded = em2.find(NanoPrecisionEntity.class, id);
DEMO.info("H2 2.4.240: original LocalDateTime nanos = {}", withNanos.getNano());
DEMO.info("H2 2.4.240: plain column (precision default) nanos = {} (value={})",
loaded.getPlainLocalDateTime().getNano(), loaded.getPlainLocalDateTime());
DEMO.info("H2 2.4.240: @Column(precision=9) column nanos = {} (value={})",
loaded.getHighPrecisionLocalDateTime().getNano(), loaded.getHighPrecisionLocalDateTime());
DEMO.info("H2 2.4.240: original Instant nanos = {}", instantWithNanos.getNano());
DEMO.info("H2 2.4.240: plain Instant column nanos = {} (value={})",
loaded.getPlainInstant().getNano(), loaded.getPlainInstant());
em2.close();
}
}
@@ -0,0 +1,45 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "@Temporal verified".
* The application context bootstrapping successfully with {@link TemporalOnJavaTimeEntity} on
* the classpath is itself the finding: Hibernate 7.4.5 does NOT reject the misuse at boot.
*/
@SpringBootTest
class TemporalAnnotationTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void temporalAnnotationOnInstantIsSilentlyIgnored_bootSucceedsAndValueRoundTrips() {
Instant now = Instant.parse("2026-05-20T09:15:30Z");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
TemporalOnJavaTimeEntity e = new TemporalOnJavaTimeEntity();
e.setInstantWithTemporalAnnotation(now);
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
TemporalOnJavaTimeEntity loaded = em2.find(TemporalOnJavaTimeEntity.class, id);
DEMO.info("@Temporal(TIMESTAMP) on Instant field: boot succeeded, round-tripped value = {} (expected {})",
loaded.getInstantWithTemporalAnnotation(), now);
em2.close();
org.assertj.core.api.Assertions.assertThat(loaded.getInstantWithTemporalAnnotation()).isEqualTo(now);
}
}
@@ -0,0 +1,61 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.TimeZone;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "The central experiment".
* Stores an {@code OffsetDateTime} with offset {@code +05:30} through every
* {@code @TimeZoneStorage} mode and logs exactly what offset comes back. Run this same test
* under different {@code -Duser.timezone} values (see docs/output capture commands) to observe
* the JVM-default-timezone hazard.
*/
@SpringBootTest
class TimeZoneStorageTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void storeNonUtcOffsetAndReadBackUnderEveryMode() {
OffsetDateTime original = OffsetDateTime.of(2026, 6, 15, 14, 0, 0, 0, ZoneOffset.ofHoursMinutes(5, 30));
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
TimeZoneStorageEntity e = new TimeZoneStorageEntity();
e.setNoAnnotation(original);
e.setNativeMode(original);
e.setNormalizeMode(original);
e.setNormalizeUtcMode(original);
e.setColumnMode(original);
e.setAutoMode(original);
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
TimeZoneStorageEntity loaded = em2.find(TimeZoneStorageEntity.class, id);
DEMO.info("JVM user.timezone system property = {}", System.getProperty("user.timezone"));
DEMO.info("JVM TimeZone.getDefault() = {}", TimeZone.getDefault().getID());
DEMO.info("ORIGINAL stored = {}", original);
DEMO.info("TZ_MODE no-annotation (default) = {}", loaded.getNoAnnotation());
DEMO.info("TZ_MODE NATIVE = {}", loaded.getNativeMode());
DEMO.info("TZ_MODE NORMALIZE = {}", loaded.getNormalizeMode());
DEMO.info("TZ_MODE NORMALIZE_UTC = {}", loaded.getNormalizeUtcMode());
DEMO.info("TZ_MODE COLUMN = {}", loaded.getColumnMode());
DEMO.info("TZ_MODE AUTO = {}", loaded.getAutoMode());
em2.close();
}
}
@@ -0,0 +1,119 @@
package com.ankurm.hibernatedemo.hikari;
import static org.assertj.core.api.Assertions.assertThat;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
/**
* The original article's "leak detection" advice was a single line -- "set
* leakDetectionThreshold to catch connections that are opened but never closed" -- with no
* mention of a floor value. Measuring it surfaces a trap the article missed entirely: HikariCP
* silently disables leak detection for any threshold under 2000ms, rather than honoring a short
* value for a fast test or a tight dev-loop check.
*
* <p>Both behaviors are confirmed by disassembling {@code HikariConfig.class} in {@code
* HikariCP-7.0.2.jar}: {@code validate()} logs {@code "{} - leakDetectionThreshold is less than
* 2000ms or more than maxLifetime, disabling it."} via {@code HikariConfig}'s own logger and
* resets the field to {@code 0} (disabled) whenever the configured value is under 2000ms -- this
* runs synchronously inside the {@code HikariDataSource} constructor, before any connection is
* ever checked out.
*
* <p>Docs: docs/19-hikaricp-connection-pooling.md
*/
class HikariLeakDetectionTest {
private HikariDataSource dataSource;
@AfterEach
void tearDown() {
if (dataSource != null) {
dataSource.close();
}
}
@Test
void thresholdUnder2000ms_isSilentlyDisabled_notShortenedToWhatWasAsked() {
Logger configLogger = (Logger) LoggerFactory.getLogger(HikariConfig.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
configLogger.addAppender(appender);
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:hikarileakshort;DB_CLOSE_DELAY=-1");
config.setUsername("sa");
config.setLeakDetectionThreshold(500); // the trap: under the 2000ms floor
dataSource = new HikariDataSource(config); // validate() runs here, synchronously
System.out.println("RESULT[hikari-leak-threshold-floor]: requested leakDetectionThreshold=500ms | "
+ "actual leakDetectionThreshold after construction=" + config.getLeakDetectionThreshold()
+ "ms | logged warnings=" + appender.list.size()
+ (appender.list.isEmpty() ? "" : " | message=" + appender.list.get(0).getFormattedMessage())
+ " -- HikariCP does not clamp 500ms up to 2000ms, it disables leak detection "
+ "entirely and logs a WARN naming the reason.");
assertThat(config.getLeakDetectionThreshold())
.as("HikariConfig.validate() resets a sub-2000ms threshold to 0 (disabled), it does "
+ "not round it up to the 2000ms floor")
.isZero();
assertThat(appender.list)
.as("and it says so, once, at construction time")
.hasSize(1);
assertThat(appender.list.get(0).getFormattedMessage())
.contains("leakDetectionThreshold is less than 2000ms");
configLogger.detachAppender(appender);
}
@Test
void thresholdAtOrAbove2000ms_actuallyLogsAWarning_whenAConnectionIsHeldPastIt() throws Exception {
Logger leakTaskLogger = (Logger) LoggerFactory.getLogger("com.zaxxer.hikari.pool.ProxyLeakTask");
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
leakTaskLogger.addAppender(appender);
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:hikarileak;DB_CLOSE_DELAY=-1");
config.setUsername("sa");
config.setMaximumPoolSize(2);
config.setLeakDetectionThreshold(2000); // the floor itself -- the shortest value that "counts"
dataSource = new HikariDataSource(config);
assertThat(config.getLeakDetectionThreshold())
.as("2000ms is exactly at the floor, so unlike 500ms above, it is NOT disabled")
.isEqualTo(2000L);
// Deliberately leaked: checked out and never closed within this test method.
Connection leaked = dataSource.getConnection();
long deadline = System.currentTimeMillis() + 8000;
while (appender.list.isEmpty() && System.currentTimeMillis() < deadline) {
Thread.sleep(100);
}
assertThat(appender.list)
.as("HikariCP's own leak-detection background task should have logged by now")
.isNotEmpty();
String warning = appender.list.get(0).getFormattedMessage();
System.out.println("RESULT[hikari-leak-detection]: leakDetectionThreshold=2000ms | logger="
+ leakTaskLogger.getName() + " | level=" + appender.list.get(0).getLevel()
+ " | message=" + warning);
assertThat(warning)
.as("HikariCP's own leak-detection task logs a WARN naming the pool and the stack "
+ "trace of where the connection was checked out, once it's been held longer "
+ "than leakDetectionThreshold")
.contains("Connection leak detection triggered");
leaked.close(); // clean up now that the test has what it needs
leakTaskLogger.detachAppender(appender);
}
}
@@ -0,0 +1,84 @@
package com.ankurm.hibernatedemo.hikari;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.SQLTransientConnectionException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
/**
* The failure mode a pool-sizing article should lead with: what actually happens when every
* connection in the pool is checked out and something else asks for one more. Not "it blocks
* forever" and not a generic timeout -- HikariCP throws a specific, named exception with the pool
* name and the timeout value baked into the message.
*
* <p>Docs: docs/19-hikaricp-connection-pooling.md
*/
class HikariPoolExhaustionTest {
private HikariDataSource dataSource;
@AfterEach
void tearDown() {
if (dataSource != null) {
dataSource.close();
}
}
@Test
void requestingOneMoreConnectionThanMaximumPoolSize_failsFastWithSqlTransientConnectionException()
throws Exception {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:hikariexhaustion;DB_CLOSE_DELAY=-1");
config.setUsername("sa");
config.setPoolName("exhaustion-pool");
config.setMaximumPoolSize(1);
config.setConnectionTimeout(1000); // fail fast for the test, default is 30000ms
dataSource = new HikariDataSource(config);
// Hold the pool's one and only connection open.
Connection held = dataSource.getConnection();
long start = System.currentTimeMillis();
Throwable thrown = catchThrowable(() -> dataSource.getConnection());
long waitedMs = System.currentTimeMillis() - start;
System.out.println("RESULT[hikari-pool-exhaustion]: maximumPoolSize=1, connectionTimeout=1000ms | "
+ "second getConnection() waited=" + waitedMs + "ms before throwing "
+ thrown.getClass().getName() + ": " + thrown.getMessage());
assertThat(thrown).isInstanceOf(SQLTransientConnectionException.class);
assertThat(thrown.getMessage())
.as("the exception message names the pool and states it's a connection-timeout, "
+ "not a generic SQL error -- this is what to grep application logs for")
.contains("exhaustion-pool")
.contains("Connection is not available");
assertThat(waitedMs)
.as("the caller waits roughly connectionTimeout, not forever and not instantly")
.isGreaterThanOrEqualTo(900);
held.close();
// With the held connection released, a new request succeeds immediately.
try (Connection recovered = dataSource.getConnection()) {
assertThat(recovered.isValid(1)).isTrue();
}
}
private static Throwable catchThrowable(ThrowingCallable callable) {
try {
callable.call();
return null;
} catch (Throwable t) {
return t;
}
}
@FunctionalInterface
private interface ThrowingCallable {
void call() throws Exception;
}
}
@@ -0,0 +1,102 @@
package com.ankurm.hibernatedemo.hikari;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariDataSource;
import org.hibernate.SessionFactory;
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.engine.jdbc.connections.spi.ConnectionProvider;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
/**
* The non-Spring side of this chapter: a plain {@code StandardServiceRegistry} -- no Spring Boot
* autoconfiguration anywhere -- explicitly wired to HikariCP via {@code hibernate-hikaricp}
* (the {@code org.hibernate.orm:hibernate-hikaricp} integration jar, added to this project's
* {@code pom.xml} test scope specifically for this chapter). This is the style a Java SE
* application, a batch job, or the original article's own non-Spring example would use.
*
* <p>{@code hibernate.connection.provider_class} accepts the short name {@code hikari} or {@code
* hikaricp} (both registered, confirmed by disassembling {@code
* StrategyRegistrationProviderImpl.class} in {@code hibernate-hikaricp-7.4.5.Final.jar}) as well
* as the fully-qualified {@code org.hibernate.hikaricp.internal.HikariCPConnectionProvider}.
* Every {@code hibernate.hikari.*}-prefixed setting (confirmed via the same jar's {@code
* HikariConfigurationUtil.CONFIG_PREFIX} constant, {@code "hibernate.hikari."}) is copied
* directly onto the underlying {@code HikariConfig} by property name -- {@code
* hibernate.hikari.maximumPoolSize} sets {@code HikariConfig#setMaximumPoolSize}, {@code
* hibernate.hikari.poolName} sets {@code HikariConfig#setPoolName}, and so on.
*
* <p>Docs: docs/19-hikaricp-connection-pooling.md
*/
class HikariRawBootstrapTest {
private StandardServiceRegistry registry;
private SessionFactory sessionFactory;
@AfterEach
void tearDown() {
if (sessionFactory != null) {
sessionFactory.close();
}
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
}
}
@Test
void rawStandardServiceRegistry_wiredToHikariByShortName_actuallyUsesHikariUnderneath() {
registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:hikarirawbootstrap;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.hbm2ddl.auto", "create")
.applySetting("hibernate.connection.provider_class", "hikari")
.applySetting("hibernate.hikari.maximumPoolSize", "7")
.applySetting("hibernate.hikari.poolName", "hibernate-demo-ch19-pool")
.applySetting("hibernate.hikari.connectionTimeout", "5000")
.build();
Metadata metadata = new MetadataSources(registry)
.addAnnotatedClass(PoolProbe.class)
.buildMetadata();
sessionFactory = metadata.buildSessionFactory();
ConnectionProvider provider = sessionFactory.unwrap(SessionFactoryImplementor.class)
.getServiceRegistry()
.getService(ConnectionProvider.class);
System.out.println("RESULT[hikari-raw-bootstrap]: ConnectionProvider class="
+ provider.getClass().getName()
+ " | isUnwrappableAs(HikariDataSource)=" + provider.isUnwrappableAs(HikariDataSource.class));
assertThat(provider.getClass().getName())
.as("hibernate.connection.provider_class=hikari resolves to the real HikariCP integration")
.isEqualTo("org.hibernate.hikaricp.internal.HikariCPConnectionProvider");
assertThat(provider.isUnwrappableAs(HikariDataSource.class)).isTrue();
HikariDataSource hikari = provider.unwrap(HikariDataSource.class);
System.out.println("RESULT[hikari-raw-bootstrap-config]: poolName=" + hikari.getPoolName()
+ " | maximumPoolSize=" + hikari.getMaximumPoolSize()
+ " | connectionTimeout=" + hikari.getConnectionTimeout() + "ms"
+ " -- every value traces back to a hibernate.hikari.* setting passed into "
+ "StandardServiceRegistryBuilder, with zero Spring involved.");
assertThat(hikari.getPoolName()).isEqualTo("hibernate-demo-ch19-pool");
assertThat(hikari.getMaximumPoolSize()).isEqualTo(7);
assertThat(hikari.getConnectionTimeout()).isEqualTo(5000L);
// Prove the pool is actually live, not just configured: persist through it.
try (var session = sessionFactory.openSession()) {
var tx = session.beginTransaction();
session.persist(new PoolProbe("raw-bootstrap-probe"));
tx.commit();
}
assertThat(hikari.getHikariPoolMXBean().getTotalConnections())
.as("the pool actually opened at least one real connection to service that persist()")
.isGreaterThanOrEqualTo(1);
}
}
@@ -0,0 +1,55 @@
package com.ankurm.hibernatedemo.hikari;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Checks the original article's opening claim -- "Spring Boot uses HikariCP by default since 2.0"
* -- against the actual bean in this project's own Spring context, rather than repeating the
* version number from memory. `application.yml` in this repo names no connection-pool type at
* all: no {@code spring.datasource.type}, no {@code spring.datasource.hikari.*} block, nothing.
* The {@code DataSource} bean Spring Boot 4.1.1 hands out anyway is a real {@code
* HikariDataSource} -- confirmed here, not assumed.
*
* <p>Docs: docs/19-hikaricp-connection-pooling.md
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class SpringAutoConfiguredHikariTest {
@Autowired
private DataSource dataSource;
@Test
void springBootDataSourceBean_isHikariByDefault_withNoExplicitPoolConfiguration() {
assertThat(dataSource)
.as("with no spring.datasource.type and no spring.datasource.hikari.* block "
+ "anywhere in application.yml, Spring Boot's own default connection pool "
+ "choice is still HikariCP")
.isInstanceOf(HikariDataSource.class);
HikariDataSource hikari = (HikariDataSource) dataSource;
System.out.println("RESULT[hikari-spring-default]: dataSource class=" + dataSource.getClass().getName()
+ " | pool name=" + hikari.getPoolName()
+ " | maximumPoolSize=" + hikari.getMaximumPoolSize()
+ " | minimumIdle=" + hikari.getMinimumIdle()
+ " | connectionTimeout=" + hikari.getConnectionTimeout() + "ms"
+ " | idleTimeout=" + hikari.getIdleTimeout() + "ms"
+ " -- these are HikariCP's own built-in defaults (maximumPoolSize=10, "
+ "minimumIdle defaults to maximumPoolSize), not anything this project set.");
assertThat(hikari.getMaximumPoolSize())
.as("HikariCP's own documented default pool size, unless overridden")
.isEqualTo(10);
assertThat(hikari.getPoolName())
.as("with no spring.datasource.hikari.pool-name set, Spring Boot lets HikariCP "
+ "generate its own default name (HikariPool-N)")
.startsWith("HikariPool-");
}
}
@@ -0,0 +1,203 @@
package com.ankurm.hibernatedemo.interceptor;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
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.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Proves chapter 24's interceptor claims: implementing {@code Interceptor} directly (no
* {@code EmptyInterceptor} base class needed), the {@code state}-array-mutation contract on
* {@code onSave}/{@code onFlushDirty}, session-scoped vs. globally-registered interceptors (the
* property Spring Boot's {@code HibernatePropertiesCustomizer} sets under the hood), and bulk
* HQL updates bypassing interceptor callbacks entirely.
*
* <p>Docs: docs/24-interceptors.md.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class InterceptorTest {
@Autowired
private EntityManagerFactory emf;
private StandardServiceRegistry nativeRegistry;
private SessionFactory nativeSessionFactory;
@AfterEach
void tearDown() {
if (nativeSessionFactory != null) {
nativeSessionFactory.close();
}
if (nativeRegistry != null) {
StandardServiceRegistryBuilder.destroy(nativeRegistry);
}
}
@Test
void sessionScopedInterceptor_mutatesStateArray_onSaveAndOnFlushDirty() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
UppercasingInterceptor interceptor = new UppercasingInterceptor();
Long id;
try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) {
session.getTransaction().begin();
Task task = new Task("wash the car", 3);
session.persist(task);
session.getTransaction().commit();
id = task.getId();
}
String persistedNameAfterInsert;
try (Session plain = sessionFactory.openSession()) {
persistedNameAfterInsert = plain.find(Task.class, id).getName();
}
try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) {
session.getTransaction().begin();
Task task = session.find(Task.class, id);
task.setName("buy milk");
session.getTransaction().commit();
}
String persistedNameAfterUpdate;
try (Session plain = sessionFactory.openSession()) {
persistedNameAfterUpdate = plain.find(Task.class, id).getName();
}
System.out.println("RESULT[interceptor-session-scoped-mutation]: onSave called "
+ interceptor.getOnSaveCalls() + " time(s), onFlushDirty called "
+ interceptor.getOnFlushDirtyCalls() + " time(s) -- name after insert, reloaded "
+ "from the database: '" + persistedNameAfterInsert + "' | name after update, "
+ "reloaded from the database: '" + persistedNameAfterUpdate + "' -- both "
+ "mutations happened inside the interceptor's state array, not in application "
+ "code, and both are visible in what was actually persisted.");
assertThat(interceptor.getOnSaveCalls()).isEqualTo(1);
assertThat(interceptor.getOnFlushDirtyCalls()).isEqualTo(1);
assertThat(persistedNameAfterInsert).isEqualTo("WASH THE CAR");
assertThat(persistedNameAfterUpdate).isEqualTo("BUY MILK");
}
@Test
void plainSessionWithoutInterceptor_leavesNameUnchanged() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Long id;
try (Session session = sessionFactory.openSession()) {
session.getTransaction().begin();
Task task = new Task("mow the lawn", 1);
session.persist(task);
session.getTransaction().commit();
id = task.getId();
}
String name;
try (Session session = sessionFactory.openSession()) {
name = session.find(Task.class, id).getName();
}
System.out.println("RESULT[interceptor-scoping]: a plain sessionFactory.openSession() "
+ "with no interceptor supplied left the name exactly as the application wrote "
+ "it -- '" + name + "' -- the interceptor used by the previous test is scoped to"
+ " the specific Session it was passed to via withOptions().interceptor(...), not"
+ " to the SessionFactory as a whole.");
assertThat(name).isEqualTo("mow the lawn");
}
@Test
void globalInterceptorViaSessionFactoryInterceptorProperty_appliesToEverySessionAutomatically() {
// What Spring Boot's HibernatePropertiesCustomizer does under the hood is set exactly
// this property -- hibernate.session_factory.interceptor -- on the properties map before
// the SessionFactory is built, so every Session obtained from it carries the interceptor
// without any per-session withOptions() call. Demonstrated here on a standalone,
// non-Spring registry so it doesn't retroactively change every OTHER test's shared
// Spring-managed SessionFactory.
UppercasingInterceptor globalInterceptor = new UppercasingInterceptor();
nativeRegistry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url",
"jdbc:h2:mem:globalinterceptortest;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.hbm2ddl.auto", "create")
.applySetting("hibernate.session_factory.interceptor", globalInterceptor)
.build();
Metadata metadata = new MetadataSources(nativeRegistry)
.addAnnotatedClass(Task.class)
.buildMetadata();
nativeSessionFactory = metadata.buildSessionFactory();
// Two entirely separate, unrelated openSession() calls -- neither one asks for the
// interceptor explicitly.
try (Session s1 = nativeSessionFactory.openSession()) {
s1.getTransaction().begin();
s1.persist(new Task("first task", 1));
s1.getTransaction().commit();
}
try (Session s2 = nativeSessionFactory.openSession()) {
s2.getTransaction().begin();
s2.persist(new Task("second task", 2));
s2.getTransaction().commit();
}
System.out.println("RESULT[interceptor-global-via-property]: hibernate.session_factory."
+ "interceptor set once at SessionFactory build time -- onSave fired "
+ globalInterceptor.getOnSaveCalls() + " times across 2 independent "
+ "openSession() calls that never mentioned the interceptor themselves -- this is"
+ " the mechanism a Spring Boot HibernatePropertiesCustomizer bean uses to "
+ "register an interceptor application-wide.");
assertThat(globalInterceptor.getOnSaveCalls()).isEqualTo(2);
}
@Test
void bulkHqlUpdate_bypassesInterceptorCallbacksEntirely() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
UppercasingInterceptor interceptor = new UppercasingInterceptor();
Long id;
try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) {
session.getTransaction().begin();
Task task = new Task("bulk-update-seed-task", 5);
session.persist(task);
session.getTransaction().commit();
id = task.getId();
}
int onSaveCallsAfterInsert = interceptor.getOnSaveCalls();
try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) {
session.getTransaction().begin();
session.createMutationQuery("update Task set name = 'renamed by bulk update' where id = :id")
.setParameter("id", id)
.executeUpdate();
session.getTransaction().commit();
}
int onFlushDirtyCallsAfterBulkUpdate = interceptor.getOnFlushDirtyCalls();
String persistedName;
try (Session session = sessionFactory.openSession()) {
persistedName = session.find(Task.class, id).getName();
}
System.out.println("RESULT[interceptor-bulk-update-bypass]: onSaveCalls after the initial"
+ " insert=" + onSaveCallsAfterInsert + " | onFlushDirtyCalls after a bulk "
+ "'update Task set name = ...' executeUpdate()=" + onFlushDirtyCallsAfterBulkUpdate
+ " (still 0) | actual persisted name: '" + persistedName + "' -- the bulk HQL "
+ "statement changed the row directly in the database without loading a Task "
+ "instance into the persistence context at all, so onFlushDirty (which needs a"
+ " managed entity's dirty state to fire against) never had anything to call.");
assertThat(onFlushDirtyCallsAfterBulkUpdate).isZero();
assertThat(persistedName).isEqualTo("renamed by bulk update");
}
}
@@ -0,0 +1,87 @@
package com.ankurm.hibernatedemo.jndi;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NameAlreadyBoundException;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/10-mocking-jndi-datasources.md, chapter "'Name already bound' across two tests in the same JVM".
* Simple-JNDI's {@code MemoryContextFactory}, with {@code org.osjava.sj.jndi.shared=true},
* backs its bindings with a single JVM-wide static map (see
* {@code JndiDataSourceResolutionTest}'s javadoc). If test A binds a name and never unbinds it,
* test B's attempt to bind the SAME name in the SAME JVM fails with
* {@code NameAlreadyBoundException} -- even though the two tests share no code, no fixture, no
* Spring context. {@link MethodOrderer.OrderAnnotation} pins the order here deliberately, so the
* pollution is forced to happen rather than left to JUnit's default (effectively random)
* ordering.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CrossTestPollutionTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final String SHARED_NAME = "jdbc/SharedAcrossTests";
static {
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.MemoryContextFactory");
System.setProperty("org.osjava.sj.jndi.shared", "true");
}
@Test
@Order(1)
void testA_bindsAName_andDeliberatelyDoesNotCleanUp() throws Exception {
JdbcDataSource ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:pollution-a;DB_CLOSE_DELAY=-1");
Context ctx = new InitialContext();
ctx.createSubcontext("jdbc");
ctx.bind(SHARED_NAME, ds);
DEMO.info("testA bound {} -- no @AfterEach unbind on purpose, to force the pollution", SHARED_NAME);
// If this assertion passes, the bind succeeded.
assertThat(new InitialContext().lookup(SHARED_NAME)).isNotNull();
}
@Test
@Order(2)
void testB_bindingTheSameNameAgain_throwsNameAlreadyBoundException() throws Exception {
JdbcDataSource ds2 = new JdbcDataSource();
ds2.setURL("jdbc:h2:mem:pollution-b;DB_CLOSE_DELAY=-1");
Context ctx = new InitialContext();
NameAlreadyBoundException ex = assertThrows(NameAlreadyBoundException.class,
() -> ctx.bind(SHARED_NAME, ds2));
DEMO.info("testB's bind() of the SAME name testA left behind failed verbatim with: {}: {}",
ex.getClass().getName(), ex.getMessage());
// The fix: rebind() instead of bind() is idempotent for this exact failure mode --
// it replaces whatever is there instead of throwing.
ctx.rebind(SHARED_NAME, ds2);
Object rebound = new InitialContext().lookup(SHARED_NAME);
assertThat(rebound).isSameAs(ds2);
DEMO.info("ctx.rebind() instead of ctx.bind() -- the standard fix -- succeeded: {}", rebound);
}
@Test
@Order(3)
void testC_theRealFix_isAfterEachUnbindNotRebind() throws Exception {
// rebind() papers over the symptom but each test still leaks its binding into the next
// one's JNDI namespace unless something cleans up. The actual fix is symmetry: whatever
// a test binds, that same test unbinds, in @AfterEach, unconditionally.
try {
new InitialContext().unbind(SHARED_NAME);
DEMO.info("cleanup: unbound {} so it does not leak into any test that runs after this class", SHARED_NAME);
} catch (Exception e) {
DEMO.info("cleanup: nothing to unbind ({})", e.toString());
}
assertThrows(javax.naming.NameNotFoundException.class,
() -> new InitialContext().lookup(SHARED_NAME));
}
}
@@ -0,0 +1,101 @@
package com.ankurm.hibernatedemo.jndi;
import static org.assertj.core.api.Assertions.assertThat;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NameAlreadyBoundException;
import org.h2.jdbcx.JdbcDataSource;
import org.hibernate.SessionFactory;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.Configuration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/10-mocking-jndi-datasources.md, chapter "Getting Hibernate itself to resolve a JNDI DataSource by
* name". Deliberately bypasses Spring Boot's autoconfiguration entirely and goes straight at
* Hibernate's own {@code hibernate.connection.datasource} setting (see
* {@code org.hibernate.cfg.JdbcSettings.DATASOURCE} -- confirmed present in 7.4.5.Final via
* javap, docs/output/jndi-hibernate-datasource-setting-javap.txt), which is the exact mechanism
* the article's original code was trying to exercise through Spring.
*
* <p>A separate attempt to get Spring Boot's own {@code spring.datasource.jndi-name} property
* to resolve through a full {@code @SpringBootTest} context (with {@code spring-boot-starter-web}
* on the test classpath and {@code simple-jndi}'s {@code MemoryContextFactory}) reproduced a
* {@code NameNotFoundException} at Spring bean-creation time that this test's plain-Hibernate
* bootstrap does not hit at all -- see the findings report for what was and was not diagnosed
* about that gap. This test is the one that stayed green and demonstrates the real mechanism:
* a JNDI-bound {@code DataSource}, resolved by name, driving a working {@code SessionFactory}.
*/
class HibernateJndiDataSourceTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final String JNDI_NAME = "jdbc/HibernateTestDS";
@BeforeEach
void bindDataSource() throws Exception {
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.MemoryContextFactory");
System.setProperty("org.osjava.sj.jndi.shared", "true");
JdbcDataSource ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:hibernate-jndi-test;DB_CLOSE_DELAY=-1");
ds.setUser("sa");
ds.setPassword("");
Context ctx = new InitialContext();
createSubcontextIfAbsent(ctx, "jdbc");
ctx.rebind(JNDI_NAME, ds);
}
@AfterEach
void unbindAndClearFactory() throws Exception {
try {
new InitialContext().unbind(JNDI_NAME);
} catch (Exception ignored) {
}
System.clearProperty(Context.INITIAL_CONTEXT_FACTORY);
System.clearProperty("org.osjava.sj.jndi.shared");
}
// Real-world hygiene, discovered the hard way: simple-jndi's shared MemoryContext is a
// single JVM-wide namespace. If a DIFFERENT test class in the same run already created the
// "jdbc" subcontext and did not tear it down, a plain createSubcontext("jdbc") here throws
// NameAlreadyBoundException before this test even gets to the DataSource bind -- this is
// the exact cross-test pollution CrossTestPollutionTest demonstrates on purpose, caught
// here by accident the first time this suite ran as a whole rather than one class at a time.
private static void createSubcontextIfAbsent(Context ctx, String name) throws Exception {
try {
ctx.createSubcontext(name);
} catch (NameAlreadyBoundException alreadyThere) {
// fine -- another test in this JVM already created it
}
}
@Test
void hibernateResolvesDataSourceByJndiName_andBuildsAWorkingSessionFactory() {
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.datasource", JNDI_NAME)
.applySetting("hibernate.hbm2ddl.auto", "create-drop")
.build();
try {
SessionFactory sessionFactory = new Configuration().buildSessionFactory(registry);
assertThat(sessionFactory).isNotNull();
try (var session = sessionFactory.openSession()) {
Integer result = session.createNativeQuery("SELECT 1", Integer.class).getSingleResult();
DEMO.info("Hibernate SessionFactory built from JNDI name '{}' ran SELECT 1 -> {}", JNDI_NAME, result);
assertThat(result).isEqualTo(1);
} finally {
sessionFactory.close();
}
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -0,0 +1,108 @@
package com.ankurm.hibernatedemo.jndi;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.sql.SQLException;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NameAlreadyBoundException;
import javax.naming.NameNotFoundException;
import javax.naming.NamingException;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/10-mocking-jndi-datasources.md (post 4871 rewrite), chapter "Getting simple-jndi 0.25.0 actually
* working". Uses {@code org.osjava.sj.MemoryContextFactory} directly -- no root properties
* file needed for a pure in-memory bind/lookup within one JVM. Every test here sets
* {@code java.naming.factory.initial} itself in {@code @BeforeEach} and clears the JVM-wide
* JNDI environment in {@code @AfterEach}, on purpose: JNDI's {@code InitialContext} is backed
* by static, JVM-global state (see {@code CrossTestPollutionTest} for what happens when this
* discipline is skipped).
*/
class JndiDataSourceResolutionTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final String JNDI_NAME = "java:comp/env/jdbc/TestDS";
@BeforeEach
void setJndiFactory() {
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.MemoryContextFactory");
// Without this, org.osjava.sj.MemoryContextFactory.getInitialContext() hands back a
// BRAND NEW org.osjava.sj.jndi.MemoryContext on every single `new InitialContext()`
// call (confirmed via javap -c on MemoryContextFactory.class: it branches on this exact
// property name before deciding whether to consult its static, shared context cache).
// Real application code calls `new InitialContext()` in more than one place -- a DAO
// here, a test's @BeforeAll there -- so without this flag, a bind() in one InitialContext
// instance is invisible to a lookup() via a different instance, even in the same test.
System.setProperty("org.osjava.sj.jndi.shared", "true");
}
@AfterEach
void clearJndiFactory() throws NamingException {
try {
new InitialContext().unbind(JNDI_NAME);
} catch (NamingException ignored) {
// not bound -- fine
}
System.clearProperty(Context.INITIAL_CONTEXT_FACTORY);
System.clearProperty("org.osjava.sj.jndi.shared");
}
@Test
void bindDataSourceIntoMockJndi_andLookItUpByName() throws Exception {
JdbcDataSource ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:jnditestdb;DB_CLOSE_DELAY=-1");
ds.setUser("sa");
ds.setPassword("");
Context ctx = new InitialContext();
createSubcontextIfAbsent(ctx, "java:");
createSubcontextIfAbsent(ctx, "java:comp");
createSubcontextIfAbsent(ctx, "java:comp/env");
createSubcontextIfAbsent(ctx, "java:comp/env/jdbc");
ctx.rebind(JNDI_NAME, ds);
DataSource looked = (DataSource) new InitialContext().lookup(JNDI_NAME);
assertThat(looked).isNotNull();
try (var conn = looked.getConnection()) {
assertThat(conn.isValid(2)).isTrue();
DEMO.info("looked-up DataSource produced a valid connection: {}", conn.getMetaData().getURL());
}
}
@Test
void noInitialContextFactoryProperty_throwsNoInitialContextException() {
System.clearProperty(Context.INITIAL_CONTEXT_FACTORY);
javax.naming.NoInitialContextException ex = assertThrows(
javax.naming.NoInitialContextException.class,
() -> new InitialContext().lookup("java:comp/env/jdbc/Anything"));
DEMO.info("verbatim NoInitialContextException message: {}", ex.getMessage());
assertThat(ex.getMessage()).contains("Need to specify class name in environment or system property");
assertThat(ex.getMessage()).contains(Context.INITIAL_CONTEXT_FACTORY);
}
@Test
void lookupOfUnboundName_throwsNameNotFoundException() throws NamingException {
Context ctx = new InitialContext();
NameNotFoundException ex = assertThrows(NameNotFoundException.class,
() -> ctx.lookup("java:comp/env/jdbc/DoesNotExist"));
DEMO.info("verbatim NameNotFoundException message: {}", ex.getMessage());
}
// See docs/10-mocking-jndi-datasources.md's cross-test-pollution chapter -- this suite tripped over its own
// advice the first time it ran as a whole rather than one class at a time.
private static void createSubcontextIfAbsent(Context ctx, String name) throws Exception {
try {
ctx.createSubcontext(name);
} catch (NameAlreadyBoundException alreadyThere) {
// fine -- another test in this JVM already created it
}
}
}
@@ -0,0 +1,58 @@
package com.ankurm.hibernatedemo.mappingstyle;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.persister.entity.EntityPersister;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* The entity's field is annotated {@code @Column(name = "annotation_name")}. The matching
* orm.xml entry maps the same field to {@code xml_name}. This test reads back the ACTUAL
* column name Hibernate used, straight from the runtime metamodel and from the real DDL/SQL,
* to settle who wins.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
@TestPropertySource(properties = {
"spring.jpa.mapping-resources=orm-xml-override-mapping.xml"
})
class AnnotationXmlOverrideTest {
@Autowired
private EntityManagerFactory emf;
@Test
void xmlColumnMapping_winsOverAnnotationColumnMapping() {
SessionFactoryImplementor sfi = emf.unwrap(SessionFactoryImplementor.class);
EntityPersister persister = sfi.getMappingMetamodel()
.getEntityDescriptor(OverrideEntity.class);
String[] columnNames = persister.getPropertyColumnNames("value");
System.out.println("RESULT[override]: runtime column name for OverrideEntity.value = " + columnNames[0]
+ " (annotation said 'annotation_name', orm.xml said 'xml_name')");
assertThat(columnNames).containsExactly("xml_name");
// Prove it end-to-end: actually persist and read the row back through the winning column.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
OverrideEntity e = new OverrideEntity("hello");
em.persist(e);
em.getTransaction().commit();
em.close();
EntityManager em2 = emf.createEntityManager();
Object row = em2.createNativeQuery("select xml_name from override_entity where id = " + e.getId())
.getSingleResult();
em2.close();
assertThat(row).isEqualTo("hello");
System.out.println("RESULT[override]: native query against column 'xml_name' returned: " + row);
}
}
@@ -0,0 +1,79 @@
package com.ankurm.hibernatedemo.mappingstyle;
import org.hibernate.SessionFactory;
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.cfg.MappingSettings;
import org.junit.jupiter.api.Test;
/**
* Empirical probe: what does Hibernate 7.4.5.Final actually do with a legacy
* {@code .hbm.xml} mapping file, bypassing Spring entirely so every setting is explicit.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1.
*/
class HbmXmlBootTest {
private StandardServiceRegistry baseRegistry() {
return new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmtest;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.connection.password", "")
.applySetting("hibernate.hbm2ddl.auto", "create")
.build();
}
@Test
void defaultSettings_bootingWithHbmXml_printsWhatHappens() {
StandardServiceRegistry registry = baseRegistry();
try {
Metadata metadata = new MetadataSources(registry)
.addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml")
.buildMetadata();
SessionFactory sf = metadata.buildSessionFactory();
System.out.println("RESULT[default]: BOOT SUCCEEDED, SessionFactory built without hibernate.transform_hbm_xml.enabled");
sf.close();
} catch (Throwable t) {
System.out.println("RESULT[default]: BOOT FAILED: " + t.getClass().getName() + ": " + t.getMessage());
Throwable cause = t.getCause();
while (cause != null) {
System.out.println("RESULT[default]: caused by: " + cause.getClass().getName() + ": " + cause.getMessage());
cause = cause.getCause();
}
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
@Test
void transformHbmXmlEnabled_bootingWithHbmXml_printsWhatHappens() {
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmtest2;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.connection.password", "")
.applySetting("hibernate.hbm2ddl.auto", "create")
.applySetting(MappingSettings.TRANSFORM_HBM_XML, "true")
.build();
try {
Metadata metadata = new MetadataSources(registry)
.addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml")
.buildMetadata();
SessionFactory sf = metadata.buildSessionFactory();
System.out.println("RESULT[transform=true]: BOOT SUCCEEDED, SessionFactory built WITH " + MappingSettings.TRANSFORM_HBM_XML + "=true");
sf.close();
} catch (Throwable t) {
System.out.println("RESULT[transform=true]: BOOT FAILED: " + t.getClass().getName() + ": " + t.getMessage());
Throwable cause = t.getCause();
while (cause != null) {
System.out.println("RESULT[transform=true]: caused by: " + cause.getClass().getName() + ": " + cause.getMessage());
cause = cause.getCause();
}
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -0,0 +1,58 @@
package com.ankurm.hibernatedemo.mappingstyle;
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.cfg.MappingSettings;
import org.junit.jupiter.api.Test;
/**
* Proves the hbm.xml-mapped entity actually round-trips through the database -- not just
* that metadata parses. Docs: docs/04-annotations-vs-xml.md, Topic 1.
*/
class HbmXmlRuntimeTest {
@Test
void hbmXmlEntity_defaultSettings_actuallyPersistsAndQueries() {
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmruntime1;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.connection.password", "")
.applySetting("hibernate.hbm2ddl.auto", "create")
// NOTE: hibernate.transform_hbm_xml.enabled is deliberately NOT set here.
.build();
try {
Metadata metadata = new MetadataSources(registry)
.addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml")
.buildMetadata();
try (SessionFactory sf = metadata.buildSessionFactory()) {
Long id;
try (Session s = sf.openSession()) {
Transaction tx = s.beginTransaction();
HbmEmployee e = new HbmEmployee("Ada", "[email protected]");
s.persist(e);
tx.commit();
id = e.getId();
}
try (Session s = sf.openSession()) {
HbmEmployee loaded = s.get(HbmEmployee.class, id);
assertThat(loaded).isNotNull();
assertThat(loaded.getFirstName()).isEqualTo("Ada");
assertThat(loaded.getEmail()).isEqualTo("[email protected]");
System.out.println("RESULT[hbm-default-runtime]: persisted+loaded id=" + id
+ " firstName=" + loaded.getFirstName() + " email=" + loaded.getEmail()
+ " -- NO hibernate.transform_hbm_xml.enabled setting was applied.");
}
}
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.mappingstyle;
import org.hibernate.SessionFactory;
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.cfg.MappingSettings;
import org.junit.jupiter.api.Test;
/** Explicitly setting transform_hbm_xml.enabled=false -- does it throw or refuse? */
class HbmXmlTransformFalseTest {
@Test
void transformExplicitlyFalse_bootingWithHbmXml_printsWhatHappens() {
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:hbmtest3;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.connection.password", "")
.applySetting("hibernate.hbm2ddl.auto", "create")
.applySetting(MappingSettings.TRANSFORM_HBM_XML, "false")
.build();
try {
Metadata metadata = new MetadataSources(registry)
.addResource("com/ankurm/hibernatedemo/mappingstyle/HbmEmployee.hbm.xml")
.buildMetadata();
SessionFactory sf = metadata.buildSessionFactory();
System.out.println("RESULT[transform=false]: BOOT SUCCEEDED even with transform_hbm_xml.enabled=false");
sf.close();
} catch (Throwable t) {
System.out.println("RESULT[transform=false]: BOOT FAILED: " + t.getClass().getName() + ": " + t.getMessage());
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -0,0 +1,53 @@
package com.ankurm.hibernatedemo.mappingstyle;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* A @NaturalId concept, expressed in Hibernate's native "mapping.xml" XML dialect
* (namespace http://www.hibernate.org/xsd/orm/mapping) on a class with ZERO annotations.
* The JPA-standard orm.xml dialect (orm_3_2.xsd) has no &lt;natural-id&gt; element -- this is a
* Hibernate-only extension that only the extended XML dialect (or Java annotations) can express.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
@TestPropertySource(properties = {
"spring.jpa.mapping-resources=mapping-xml-natural-id.xml"
})
class MappingXmlNaturalIdTest {
@Autowired
private EntityManagerFactory emf;
@Test
void naturalIdDefinedPurelyInXml_resolvesViaByNaturalId() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
MappingXmlNaturalIdEntity e = new MappingXmlNaturalIdEntity("SKU-XML-1", "XML Widget");
em.persist(e);
em.getTransaction().commit();
em.close();
EntityManager em2 = emf.createEntityManager();
Session session = em2.unwrap(Session.class);
MappingXmlNaturalIdEntity loaded = session.byNaturalId(MappingXmlNaturalIdEntity.class)
.using("sku", "SKU-XML-1")
.load();
em2.close();
assertThat(loaded).isNotNull();
assertThat(loaded.getName()).isEqualTo("XML Widget");
System.out.println("RESULT[mapping-xml-natural-id]: session.byNaturalId() resolved an entity whose "
+ "@NaturalId-equivalent was declared ENTIRELY in Hibernate's native mapping.xml dialect, "
+ "zero Java annotations. name=" + loaded.getName());
}
}
@@ -0,0 +1,56 @@
package com.ankurm.hibernatedemo.mappingstyle;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Proves that {@code spring.jpa.mapping-resources} is the Spring Boot 4.1.1 wiring that makes
* an orm.xml-only mapping (no {@code @Entity} at all) a real managed JPA entity, backed by a
* table ({@code xml_only_widgets}) that ONLY orm.xml knows about.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
@TestPropertySource(properties = {
"spring.jpa.mapping-resources=orm-xml-only-mapping.xml"
})
class OrmXmlMappingResourcesTest {
@Autowired
private EntityManagerFactory emf;
@Test
void ormXmlOnlyEntity_isManaged_andRoundTripsThroughItsOwnTable() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
OrmXmlOnlyEntity e = new OrmXmlOnlyEntity("mapped-by-orm-xml-only");
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
assertThat(id).isNotNull();
EntityManager em2 = emf.createEntityManager();
// This JPQL uses the entity name (defaults to simple class name) -- proves the entity is
// registered in metadata purely via orm.xml, with zero annotations on the Java class.
List<OrmXmlOnlyEntity> found = em2.createQuery(
"select o from OrmXmlOnlyEntity o where o.label = :label", OrmXmlOnlyEntity.class)
.setParameter("label", "mapped-by-orm-xml-only")
.getResultList();
em2.close();
assertThat(found).hasSize(1);
assertThat(found.get(0).getId()).isEqualTo(id);
System.out.println("RESULT[orm-xml-only]: persisted+queried id=" + id
+ " via JPQL against entity mapped ENTIRELY by orm.xml (table xml_only_widgets), zero annotations on the Java class.");
}
}
@@ -0,0 +1,46 @@
package com.ankurm.hibernatedemo.mappingstyle;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import org.hibernate.AnnotationException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/**
* &lt;xml-mapping-metadata-complete/&gt; makes Hibernate IGNORE ALL annotations for this
* persistence unit -- not just the ones a matching orm.xml entry overrides. OverrideEntity
* carries @Id/@GeneratedValue on "id" in Java, but the metadata-complete orm.xml entry only
* declares &lt;basic name="value"&gt; and omits &lt;id&gt; entirely. Result: bootstrap fails because
* the (ignored) @Id annotation no longer counts -- proving metadata-complete is not just an
* override switch, it is a full annotation kill-switch for the persistence unit.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1.
*/
class XmlMappingMetadataCompleteTest {
@Test
void metadataComplete_ignoresAtIdAnnotation_bootFailsWithNoIdentifier() {
SpringApplicationBuilder builder = new SpringApplicationBuilder(HibernateDemoApplication.class)
.properties("spring.jpa.mapping-resources=orm-xml-metadata-complete.xml");
BeanCreationException ex = assertThrows(BeanCreationException.class, () -> {
try (ConfigurableApplicationContext ctx = builder.run()) {
// never reached
}
});
Throwable root = ex;
while (root.getCause() != null) {
root = root.getCause();
}
System.out.println("RESULT[metadata-complete]: boot FAILED as predicted: " + root.getClass().getName()
+ ": " + root.getMessage());
assertThat(root).isInstanceOf(AnnotationException.class);
assertThat(root.getMessage()).contains("has no identifier");
}
}
@@ -0,0 +1,159 @@
package com.ankurm.hibernatedemo.namedquery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.NoResultException;
import jakarta.persistence.Query;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Backs ankurm.com post 4877, docs/14-named-queries.md. Covers: jakarta @NamedQuery execution,
* org.hibernate.annotations.NamedQuery's cacheable extra actually taking effect,
* @NamedNativeQuery + @SqlResultSetMapping DTO projection, record constructor-result (JPA 3.2),
* and getSingleResultOrNull() vs getSingleResult().
*/
@SpringBootTest(properties = {
"spring.jpa.properties.hibernate.cache.use_second_level_cache=true",
"spring.jpa.properties.hibernate.cache.use_query_cache=true",
"spring.jpa.properties.hibernate.cache.region.factory_class=jcache",
"spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider"
})
class NamedQueryExecutionTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM NqEmployee").executeUpdate();
em.createQuery("DELETE FROM HibernateExtraEmployee").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
@Test
void jakartaNamedQuery_executesAndReturnsExpectedRows() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new NqEmployee("Ankur", "ACTIVE"));
em.persist(new NqEmployee("Priya", "ACTIVE"));
em.getTransaction().commit();
List<NqEmployee> found = em.createNamedQuery("Employee.findByName", NqEmployee.class)
.setParameter("name", "Ankur")
.getResultList();
DEMO.info("Employee.findByName(Ankur): {} rows", found.size());
assertThat(found).hasSize(1);
em.close();
}
@Test
void hibernateNamedQuery_cacheableExtra_actuallyMarksTheQueryCacheable() {
// org.hibernate.annotations.NamedQuery offers `cacheable` -- jakarta.persistence.NamedQuery
// does not. Verify it actually takes effect using Statistics' query-cache put count.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new HibernateExtraEmployee("A"));
em.persist(new HibernateExtraEmployee("B"));
em.getTransaction().commit();
em.close();
stats().clear();
EntityManager em1 = emf.createEntityManager();
List<HibernateExtraEmployee> first = em1.createNamedQuery("HibernateExtraEmployee.cacheableFindAll", HibernateExtraEmployee.class)
.getResultList();
long putsAfterFirst = stats().getQueryCachePutCount();
em1.close();
EntityManager em2 = emf.createEntityManager();
List<HibernateExtraEmployee> second = em2.createNamedQuery("HibernateExtraEmployee.cacheableFindAll", HibernateExtraEmployee.class)
.getResultList();
long hitsAfterSecond = stats().getQueryCacheHitCount();
em2.close();
DEMO.info("cacheable=true named query: puts after 1st run = {}, cache hits after 2nd run = {}", putsAfterFirst, hitsAfterSecond);
assertThat(first).hasSize(2);
assertThat(second).hasSize(2);
assertThat(putsAfterFirst).as("cacheable=true on org.hibernate.annotations.NamedQuery must populate the query cache").isGreaterThan(0);
assertThat(hitsAfterSecond).as("second identical call must hit the query cache").isGreaterThan(0);
}
@Test
void namedNativeQuery_withSqlResultSetMapping_projectsToDto() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new NqEmployee("Native1", "ACTIVE"));
em.persist(new NqEmployee("Native2", "INACTIVE"));
em.getTransaction().commit();
@SuppressWarnings("unchecked")
List<EmployeeDto> dtos = (List<EmployeeDto>) (List<?>) em.createNamedQuery("Employee.byNativeDto")
.setParameter("status", "ACTIVE")
.getResultList();
DEMO.info("Employee.byNativeDto(ACTIVE): {}", dtos);
assertThat(dtos).hasSize(1);
assertThat(dtos.get(0).getFirstName()).isEqualTo("Native1");
em.close();
}
@Test
void jpql31ConstructorExpression_worksWithARecordAsTheTarget() {
// Jakarta Persistence 3.2 alternative to @SqlResultSetMapping: a plain JPQL constructor
// expression targeting a java `record`.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new NqEmployee("RecordTest", "ACTIVE"));
em.getTransaction().commit();
List<EmployeeRecordDto> records = em.createQuery(
"SELECT NEW com.ankurm.hibernatedemo.namedquery.EmployeeRecordDto(e.id, e.firstName) FROM NqEmployee e WHERE e.firstName = :name",
EmployeeRecordDto.class)
.setParameter("name", "RecordTest")
.getResultList();
DEMO.info("JPQL constructor expression into a record: {}", records);
assertThat(records).hasSize(1);
assertThat(records.get(0).firstName()).isEqualTo("RecordTest");
em.close();
}
@Test
void getSingleResultOrNull_vs_getSingleResult_onNoRows() {
EntityManager em = emf.createEntityManager();
Object viaOrNull = em.createQuery("SELECT e FROM NqEmployee e WHERE e.firstName = :n")
.setParameter("n", "NoSuchPersonAtAll")
.getSingleResultOrNull();
DEMO.info("getSingleResultOrNull() on zero rows returned: {}", viaOrNull);
assertThat(viaOrNull).isNull();
Query q = em.createQuery("SELECT e FROM NqEmployee e WHERE e.firstName = :n")
.setParameter("n", "NoSuchPersonAtAll");
NoResultException ex = assertThrows(NoResultException.class, q::getSingleResult);
DEMO.info("getSingleResult() on zero rows threw: {}: {}", ex.getClass().getName(), ex.getMessage());
em.close();
}
}
@@ -0,0 +1,86 @@
package com.ankurm.hibernatedemo.namedquery;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4877, docs/14-named-queries.md chapter "Does pre-parsing actually help?"
*
* <p>Measures whether a {@code @NamedQuery} is measurably faster than the identical inline JPQL
* string, after JIT/plan-cache warmup. Sandbox is a shared container -- treat absolute numbers
* as indicative only, the RATIO between the two is what matters here.
*/
@SpringBootTest(properties = {
"spring.jpa.properties.hibernate.generate_statistics=false" // avoid stats overhead skewing a micro-benchmark
})
class NamedQueryPerformanceTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int WARMUP = 500;
private static final int MEASURED = 5000;
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void seed() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM NqEmployee").executeUpdate();
for (int i = 0; i < 20; i++) {
em.persist(new NqEmployee("Perf" + i, "ACTIVE"));
}
em.getTransaction().commit();
em.close();
}
@Test
void measureNamedQueryVsInlineJpql() {
// Warmup both paths (JIT + Hibernate's internal query-plan cache, which is keyed by the
// query STRING regardless of whether it came from a @NamedQuery or an inline literal).
for (int i = 0; i < WARMUP; i++) {
runNamed();
runInline();
}
// Interleave the two so neither gets an unfair ordering/JIT/GC advantage.
long namedElapsedNs = 0;
long inlineElapsedNs = 0;
for (int i = 0; i < MEASURED; i++) {
long t0 = System.nanoTime();
runNamed();
long t1 = System.nanoTime();
runInline();
long t2 = System.nanoTime();
namedElapsedNs += (t1 - t0);
inlineElapsedNs += (t2 - t1);
}
double namedAvgUs = namedElapsedNs / 1000.0 / MEASURED;
double inlineAvgUs = inlineElapsedNs / 1000.0 / MEASURED;
DEMO.info("PERF (shared sandbox container, indicative only): {} iterations after {} warmup each",
MEASURED, WARMUP);
DEMO.info("PERF named query : total={} ms, avg={} us/call", namedElapsedNs / 1_000_000.0, namedAvgUs);
DEMO.info("PERF inline JPQL : total={} ms, avg={} us/call", inlineElapsedNs / 1_000_000.0, inlineAvgUs);
DEMO.info("PERF ratio (named/inline) = {}", namedAvgUs / inlineAvgUs);
}
private void runNamed() {
EntityManager em = emf.createEntityManager();
em.createNamedQuery("Employee.findAllActive", NqEmployee.class).getResultList();
em.close();
}
private void runInline() {
EntityManager em = emf.createEntityManager();
em.createQuery("SELECT e FROM NqEmployee e WHERE e.status = 'ACTIVE' ORDER BY e.id DESC", NqEmployee.class).getResultList();
em.close();
}
}
@@ -0,0 +1,92 @@
package com.ankurm.hibernatedemo.namedquery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.ankurm.brokenprobe.BrokenNamedQueryEmployee;
import java.util.Properties;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
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.cfg.JdbcSettings;
import org.hibernate.cfg.MappingSettings;
import org.hibernate.cfg.QuerySettings;
import org.hibernate.cfg.SchemaToolingSettings;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs ankurm.com post 4877, docs/14-named-queries.md chapter "Startup validation".
*
* <p>Uses a fully standalone Hibernate bootstrap (no Spring) specifically so the deliberately
* broken {@link BrokenNamedQueryEmployee} entity never enters the shared Spring application
* context that every other {@code @SpringBootTest} in this repository relies on.
*/
class NamedQueryStartupValidationTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private StandardServiceRegistry buildRegistry(boolean startupCheckEnabled) {
Properties props = new Properties();
props.put(JdbcSettings.JAKARTA_JDBC_URL, "jdbc:h2:mem:namedquery-startup-" + System.nanoTime() + ";DB_CLOSE_DELAY=-1");
props.put(JdbcSettings.JAKARTA_JDBC_DRIVER, "org.h2.Driver");
props.put(JdbcSettings.JAKARTA_JDBC_USER, "sa");
props.put(JdbcSettings.JAKARTA_JDBC_PASSWORD, "");
props.put(SchemaToolingSettings.HBM2DDL_AUTO, "update");
props.put(QuerySettings.QUERY_STARTUP_CHECKING, String.valueOf(startupCheckEnabled));
return new StandardServiceRegistryBuilder().applySettings(props).build();
}
@Test
void brokenNamedQuery_failsFastAtSessionFactoryBuildTime_withStartupCheckEnabled() {
StandardServiceRegistry registry = buildRegistry(true);
try {
MetadataSources sources = new MetadataSources(registry);
sources.addAnnotatedClass(BrokenNamedQueryEmployee.class);
Metadata metadata = sources.buildMetadata();
HibernateException ex = assertThrows(HibernateException.class,
() -> metadata.getSessionFactoryBuilder().build());
DEMO.info("startup_check=true (default): bootstrap failure -- wrapper class: {}", ex.getClass().getName());
DEMO.info("startup_check=true (default): verbatim message: {}", ex.getMessage());
assertThat(ex.getMessage()).contains("firsNam");
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
@Test
void brokenNamedQuery_bootsFineWithStartupCheckDisabled_failsOnlyAtCallTime() {
StandardServiceRegistry registry = buildRegistry(false);
SessionFactory sf = null;
try {
MetadataSources sources = new MetadataSources(registry);
sources.addAnnotatedClass(BrokenNamedQueryEmployee.class);
Metadata metadata = sources.buildMetadata();
// With hibernate.query.startup_check=false, this must NOT throw.
sf = metadata.getSessionFactoryBuilder().build();
DEMO.info("startup_check=false: SessionFactory built successfully with the broken named query still inside it: {}", sf != null);
SessionFactory finalSf = sf;
try (Session session = finalSf.openSession()) {
RuntimeException callTimeEx = assertThrows(RuntimeException.class,
() -> session.createNamedQuery("BrokenNamedQueryEmployee.badProperty", BrokenNamedQueryEmployee.class).getResultList());
DEMO.info("startup_check=false: query only fails when actually CALLED -- class: {}, message: {}",
callTimeEx.getClass().getName(), callTimeEx.getMessage());
}
} finally {
if (sf != null) {
sf.close();
}
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -0,0 +1,77 @@
package com.ankurm.hibernatedemo.namedquery;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4877, docs/14-named-queries.md chapter "Named queries in orm.xml".
*/
@SpringBootTest
class OrmXmlNamedQueryTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTable() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM XmlQueryEmployee").executeUpdate();
em.getTransaction().commit();
em.close();
}
@Test
void namedQueryDefinedInOrmXml_worksAlongsideTheAnnotatedOne() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new XmlQueryEmployee("Low", 30_000));
em.persist(new XmlQueryEmployee("High", 90_000));
em.getTransaction().commit();
List<XmlQueryEmployee> viaAnnotation = em.createNamedQuery("XmlQueryEmployee.findBySalaryAbove", XmlQueryEmployee.class)
.setParameter("min", 50_000.0)
.getResultList();
List<XmlQueryEmployee> viaXml = em.createNamedQuery("XmlQueryEmployee.findBySalaryAboveXml", XmlQueryEmployee.class)
.setParameter("min", 50_000.0)
.getResultList();
DEMO.info("annotation-defined named query result: {} rows", viaAnnotation.size());
DEMO.info("orm.xml-defined named query result: {} rows", viaXml.size());
assertThat(viaAnnotation).hasSize(1);
assertThat(viaXml).hasSize(1);
em.close();
}
@Test
void ormXmlNamedQuery_overridesTheSameNamedAnnotatedOne() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new XmlQueryEmployee("Whoever", 60_000));
em.getTransaction().commit();
// The annotation on XmlQueryEmployee for "overridden" is deliberately wrong (salary < 0).
// If orm.xml wins, this returns 1 row; if the annotation wins, it returns 0.
List<XmlQueryEmployee> result = em.createNamedQuery("XmlQueryEmployee.overridden", XmlQueryEmployee.class)
.setParameter("min", 50_000.0)
.getResultList();
DEMO.info("XmlQueryEmployee.overridden (annotation says salary<0, orm.xml says salary>:min): {} rows", result.size());
assertThat(result)
.as("orm.xml's definition must win over the annotation of the same name")
.hasSize(1);
em.close();
}
}
@@ -0,0 +1,88 @@
package com.ankurm.hibernatedemo.naturalid;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Proves the article's composite-natural-id code sample actually resolves, and captures the
* real generated SQL: a two-column WHERE clause that includes the FK to Company, not a join
* on the natural id of Company itself.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class CompositeNaturalIdTest {
@Autowired
private EntityManagerFactory emf;
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
@Test
void byNaturalId_compositeKey_usingTwoFields_resolvesCorrectRow() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Company acme = new Company("Acme");
Company other = new Company("Other Co");
em.persist(acme);
em.persist(other);
// Same deptCode ("ENG-01") at TWO different companies -- proves it's the (company, code)
// PAIR that is unique, not deptCode alone.
em.persist(new Department(acme, "ENG-01", "Acme Engineering"));
em.persist(new Department(other, "ENG-01", "Other Co Engineering"));
em.getTransaction().commit();
em.close();
EntityManager em2 = emf.createEntityManager();
Session session = em2.unwrap(Session.class);
stats().clear();
Department dept = session.byNaturalId(Department.class)
.using("company", acme)
.using("deptCode", "ENG-01")
.load();
long queries = stats().getPrepareStatementCount();
em2.close();
System.out.println("RESULT[composite-naturalid]: byNaturalId(company=Acme, deptCode=ENG-01) resolved to '"
+ dept.getName() + "' in " + queries + " query/queries");
assertThat(dept).isNotNull();
assertThat(dept.getName()).isEqualTo("Acme Engineering");
assertThat(queries).isEqualTo(1);
}
@Test
void byNaturalId_compositeKey_capturesTheGeneratedSql() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Company acme = new Company("SQL-Capture Co");
em.persist(acme);
em.persist(new Department(acme, "OPS-1", "Operations"));
em.getTransaction().commit();
em.close();
EntityManager em2 = emf.createEntityManager();
Session session = em2.unwrap(Session.class);
Department dept = session.byNaturalId(Department.class)
.using("company", acme)
.using("deptCode", "OPS-1")
.load();
em2.close();
assertThat(dept).isNotNull();
// The actual SQL is captured in docs/output/naturalid-composite-sql.txt via show_sql
// logging on this test run -- see that file for the verbatim SELECT.
}
}
@@ -0,0 +1,84 @@
package com.ankurm.hibernatedemo.naturalid;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Does the "base equals()/hashCode() on the natural id, not the surrogate id" advice from
* posts 4864/4865 actually hold up? Reproduces the SAME HashSet-before-flush scenario as
* {@code IdBasedEqualsHashSetTrapTest} (persistenceannotations package) but with natural-id-based
* equals/hashCode instead of surrogate-id-based -- this time contains() should stay TRUE.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class NaturalIdEqualsHashSetTest {
@Autowired
private EntityManagerFactory emf;
@Test
void naturalIdBasedEquals_addedToSetBeforeFlush_containsStaysTrueAfterPersist() {
Set<NaturalIdEqualsEntity> set = new HashSet<>();
NaturalIdEqualsEntity e = new NaturalIdEqualsEntity("SKU-EQ-1", "widget");
set.add(e); // sku is already set -- hashCode() is stable from construction
assertThat(set.contains(e)).isTrue();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(e);
em.getTransaction().commit(); // id gets assigned, but sku (and hence hashCode) never changes
em.close();
boolean stillFound = set.contains(e);
System.out.println("RESULT[naturalid-equals-hashset]: after persist(), e.getId()=" + e.getId()
+ ", e.getSku()=" + e.getSku() + ", set.contains(e) = " + stillFound
+ " (equals/hashCode based on the immutable natural id, NOT the surrogate id)");
assertThat(stillFound)
.as("natural-id-based equals/hashCode must survive persist() -- the advice holds up, unlike the surrogate-id version")
.isTrue();
}
@Test
void naturalIdBasedEquals_twoTransientInstancesWithDifferentSkus_areNotEqual() {
NaturalIdEqualsEntity a = new NaturalIdEqualsEntity("SKU-EQ-A", "Widget A");
NaturalIdEqualsEntity b = new NaturalIdEqualsEntity("SKU-EQ-B", "Widget B");
// Unlike surrogate-id equals (where two transient instances are BOTH "equal" because
// both ids are null), natural-id equals correctly distinguishes them even before persist.
System.out.println("RESULT[naturalid-equals-transient]: a.equals(b) for two DIFFERENT transient instances = "
+ a.equals(b) + " (both have null surrogate ids, but different natural ids)");
assertThat(a).isNotEqualTo(b);
}
@Test
void naturalIdBasedEquals_twoTransientInstancesWithSameSku_areEqual_evenBeforePersist() {
// This is the flip side worth knowing: TWO DIFFERENT transient objects representing
// the "same" business entity (same sku, not yet persisted) are already equal() to
// each other, well before either has a database identity. That is usually desirable,
// but it means a HashSet can silently dedupe two not-yet-persisted objects.
NaturalIdEqualsEntity a = new NaturalIdEqualsEntity("SKU-EQ-DUP", "First instance");
NaturalIdEqualsEntity b = new NaturalIdEqualsEntity("SKU-EQ-DUP", "Second instance, different name");
Set<NaturalIdEqualsEntity> set = new HashSet<>();
set.add(a);
boolean bWasRejectedAsADuplicate = !set.add(b);
System.out.println("RESULT[naturalid-equals-transient-dup]: a.equals(b)=" + a.equals(b)
+ " for two transient instances sharing sku='SKU-EQ-DUP' but different names; "
+ "HashSet.add(b) rejected it as a duplicate = " + bWasRejectedAsADuplicate);
assertThat(a).isEqualTo(b);
assertThat(bWasRejectedAsADuplicate).isTrue();
}
}
@@ -0,0 +1,92 @@
package com.ankurm.hibernatedemo.naturalid;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* THE central empirical question for post 4865: with NO second-level cache, does a
* bySimpleNaturalId lookup save a query on a second call in the SAME session? Counted with
* real Hibernate Statistics, not asserted from documentation.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class NaturalIdL1CacheTest {
@Autowired
private EntityManagerFactory emf;
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
@Test
void bySimpleNaturalId_secondCallSameSession_noL2Cache_savesTheQuery() {
EntityManager seedEm = emf.createEntityManager();
seedEm.getTransaction().begin();
seedEm.persist(new NaturalIdProduct("SKU-L1-1", "Widget"));
seedEm.getTransaction().commit();
seedEm.close();
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
stats().clear();
NaturalIdProduct first = session.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-1");
long queriesAfterFirst = stats().getPrepareStatementCount();
NaturalIdProduct second = session.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-1");
long queriesAfterSecond = stats().getPrepareStatementCount();
em.close();
System.out.println("RESULT[naturalid-l1-no-l2]: queries after 1st bySimpleNaturalId=" + queriesAfterFirst
+ ", after 2nd (same session)=" + queriesAfterSecond
+ " (no @NaturalIdCache, no L2 cache provider configured)");
assertThat(queriesAfterFirst).isEqualTo(1);
assertThat(queriesAfterSecond)
.as("second bySimpleNaturalId call in the SAME session must NOT re-fire a query -- L1 natural-id resolution cache")
.isEqualTo(1);
assertThat(second).isSameAs(first);
}
@Test
void bySimpleNaturalId_newSession_noL2Cache_reFiresTheQuery() {
EntityManager seedEm = emf.createEntityManager();
seedEm.getTransaction().begin();
seedEm.persist(new NaturalIdProduct("SKU-L1-2", "Gadget"));
seedEm.getTransaction().commit();
seedEm.close();
EntityManager em1 = emf.createEntityManager();
Session session1 = em1.unwrap(Session.class);
stats().clear();
session1.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-2");
long queriesSession1 = stats().getPrepareStatementCount();
em1.close();
EntityManager em2 = emf.createEntityManager();
Session session2 = em2.unwrap(Session.class);
session2.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-2");
long queriesAfterSession2 = stats().getPrepareStatementCount();
em2.close();
System.out.println("RESULT[naturalid-cross-session-no-l2]: queries after session 1 lookup=" + queriesSession1
+ ", cumulative after a NEW session repeats the same lookup=" + queriesAfterSession2
+ " (no L2 cache -- the L1 natural-id map dies with the session)");
assertThat(queriesAfterSession2)
.as("without L2 cache, a brand-new session must re-fire the query -- the L1 mapping does not survive session close")
.isEqualTo(queriesSession1 + 1);
}
}
@@ -0,0 +1,160 @@
package com.ankurm.hibernatedemo.naturalid;
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 other half of the central empirical question: turn ON the natural-id resolution L2
* cache (@NaturalIdCache + @Cache, backed by hibernate-jcache + ehcache) and count queries
* for a bySimpleNaturalId lookup from a SECOND, brand-new session -- something
* {@link NaturalIdL1CacheTest} proved fails (re-fires a query) without L2.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
class NaturalIdL2CacheTest {
private StandardServiceRegistry registry;
private SessionFactory sessionFactory;
private void bootWithL2Cache() {
registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:naturalidl2;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")
.build();
Metadata metadata = new MetadataSources(registry)
.addAnnotatedClass(CachedNaturalIdProduct.class)
.buildMetadata();
sessionFactory = metadata.buildSessionFactory();
}
@AfterEach
void tearDown() {
if (sessionFactory != null) {
sessionFactory.close();
}
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
}
}
@Test
void bySimpleNaturalId_withNaturalIdCache_crossSession_savesTheQuery() {
bootWithL2Cache();
Statistics stats = sessionFactory.getStatistics();
stats.clear();
Long id;
try (Session seed = sessionFactory.openSession()) {
Transaction tx = seed.beginTransaction();
CachedNaturalIdProduct p = new CachedNaturalIdProduct("SKU-L2-1", "Cached Widget");
seed.persist(p);
tx.commit();
id = p.getId();
}
System.out.println("RESULT[naturalid-l2-cache]: immediately after persist()+commit(): queries="
+ stats.getPrepareStatementCount() + ", naturalId cache put=" + stats.getNaturalIdCachePutCount()
+ ", naturalId cache hit=" + stats.getNaturalIdCacheHitCount()
+ " -- @NaturalIdCache populates the L2 region on INSERT, before anyone ever looked it up.");
stats.clear();
// Session 1: lookup right after insert. Given the surprise above, this is ALREADY a
// cache hit, not a cold DB miss -- there is no query left to save at this point.
try (Session s1 = sessionFactory.openSession()) {
CachedNaturalIdProduct p = s1.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-1");
assertThat(p.getId()).isEqualTo(id);
}
long queriesAfterSession1 = stats.getPrepareStatementCount();
// Session 2: BRAND NEW session, same natural id. With NO L2 cache (see NaturalIdL1CacheTest)
// this re-fires a query. With @NaturalIdCache + L2 enabled, it should NOT.
try (Session s2 = sessionFactory.openSession()) {
CachedNaturalIdProduct p = s2.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-1");
assertThat(p.getId()).isEqualTo(id);
}
long queriesAfterSession2 = stats.getPrepareStatementCount();
long naturalIdCacheHitsAfterSession2 = stats.getNaturalIdCacheHitCount();
System.out.println("RESULT[naturalid-l2-cache]: session1 (post-insert) cumulative queries=" + queriesAfterSession1
+ " | session2 (new session, same natural id) cumulative queries=" + queriesAfterSession2
+ ", naturalId cache hits=" + naturalIdCacheHitsAfterSession2);
// Surprise (see the persist()/commit() log line above): @NaturalIdCache populates the L2
// region on INSERT itself, so even "session1" here is already a cache hit, not a cold
// DB miss -- there is no query left to save by the time we get here.
assertThat(queriesAfterSession1)
.as("both post-insert lookups resolve entirely from the L2 natural-id cache -- zero queries")
.isEqualTo(0);
assertThat(queriesAfterSession2)
.as("session2, a BRAND NEW session, must not fire any query either -- the L2 natural-id cache resolves it")
.isEqualTo(queriesAfterSession1);
assertThat(naturalIdCacheHitsAfterSession2)
.as("both lookups (session1 and session2) register as L2 natural-id cache hits")
.isEqualTo(2L);
}
@Test
void bySimpleNaturalId_rowInsertedOutsideHibernate_firstLookupIsARealMiss_secondIsARealHit() {
bootWithL2Cache();
Statistics stats = sessionFactory.getStatistics();
// Insert via raw JDBC, bypassing Hibernate entirely -- @NaturalIdCache never gets a
// chance to populate the L2 region at insert time, unlike the persist()-based test above.
try (Session s = sessionFactory.openSession()) {
s.doWork(connection -> {
try (var stmt = connection.createStatement()) {
stmt.execute("insert into CachedNaturalIdProduct (id, sku, name) "
+ "values (999, 'SKU-L2-RAW', 'Raw Insert Widget')");
}
connection.commit();
});
}
stats.clear();
try (Session s1 = sessionFactory.openSession()) {
CachedNaturalIdProduct p = s1.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-RAW");
assertThat(p.getName()).isEqualTo("Raw Insert Widget");
}
long queriesAfterMiss = stats.getPrepareStatementCount();
long missesAfterFirst = stats.getNaturalIdCacheMissCount();
long putsAfterFirst = stats.getNaturalIdCachePutCount();
try (Session s2 = sessionFactory.openSession()) {
CachedNaturalIdProduct p = s2.bySimpleNaturalId(CachedNaturalIdProduct.class).load("SKU-L2-RAW");
assertThat(p.getName()).isEqualTo("Raw Insert Widget");
}
long queriesAfterHit = stats.getPrepareStatementCount();
long hitsAfterSecond = stats.getNaturalIdCacheHitCount();
System.out.println("RESULT[naturalid-l2-cache-real-miss-then-hit]: first lookup (genuine cold row) queries="
+ queriesAfterMiss + ", naturalId miss=" + missesAfterFirst + ", naturalId put=" + putsAfterFirst
+ " | second lookup (new session) cumulative queries=" + queriesAfterHit
+ ", naturalId hits=" + hitsAfterSecond);
assertThat(queriesAfterMiss).as("the first lookup of a row Hibernate never cached must hit the DB").isEqualTo(1);
assertThat(missesAfterFirst).isEqualTo(1L);
assertThat(putsAfterFirst).isEqualTo(1L);
assertThat(queriesAfterHit)
.as("the second lookup, from a brand-new session, must be satisfied entirely from the L2 cache")
.isEqualTo(queriesAfterMiss);
assertThat(hitsAfterSecond).isEqualTo(1L);
}
}
@@ -0,0 +1,111 @@
package com.ankurm.hibernatedemo.naturalid;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* What actually happens if you mutate a {@code @NaturalId} field Hibernate believes is
* immutable (the default), versus one explicitly marked {@code mutable = true}?
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class NaturalIdMutabilityTest {
@Autowired
private EntityManagerFactory emf;
@Test
void mutatingAnImmutableNaturalId_throwsOnFlush() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ImmutableNaturalIdEntity e = new ImmutableNaturalIdEntity("CODE-A");
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
Session session = em2.unwrap(Session.class);
session.getTransaction().begin();
ImmutableNaturalIdEntity managed = session.find(ImmutableNaturalIdEntity.class, id);
managed.setCode("CODE-B"); // mutate the field Hibernate believes is immutable
HibernateException ex = org.junit.jupiter.api.Assertions.assertThrows(
HibernateException.class,
() -> {
session.flush();
});
System.out.println("RESULT[naturalid-immutable-mutation]: flushing a changed IMMUTABLE natural id threw: "
+ ex.getClass().getName() + ": " + ex.getMessage());
session.getTransaction().rollback();
em2.close();
assertThat(ex.getMessage()).containsIgnoringCase("immutable");
}
@Test
void mutatingAMutableNaturalId_flushesFine_andUpdatesTheColumn() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
MutableNaturalIdEntity e = new MutableNaturalIdEntity("CODE-X");
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
MutableNaturalIdEntity managed = em2.find(MutableNaturalIdEntity.class, id);
managed.setCode("CODE-Y");
em2.getTransaction().commit(); // should NOT throw
em2.close();
EntityManager em3 = emf.createEntityManager();
Object raw = em3.createNativeQuery(
"select code from mutable_natural_id_entity where id = " + id).getSingleResult();
em3.close();
System.out.println("RESULT[naturalid-mutable-mutation]: flush succeeded, DB column now = " + raw);
assertThat(raw).isEqualTo("CODE-Y");
}
@Test
void mutatingAMutableNaturalId_staleLookupByOldValue_noLongerResolves() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
MutableNaturalIdEntity e = new MutableNaturalIdEntity("CODE-OLD");
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
MutableNaturalIdEntity managed = em2.find(MutableNaturalIdEntity.class, id);
managed.setCode("CODE-NEW");
em2.getTransaction().commit();
em2.close();
EntityManager em3 = emf.createEntityManager();
Session session = em3.unwrap(Session.class);
MutableNaturalIdEntity byOld = session.bySimpleNaturalId(MutableNaturalIdEntity.class).load("CODE-OLD");
MutableNaturalIdEntity byNew = session.bySimpleNaturalId(MutableNaturalIdEntity.class).load("CODE-NEW");
em3.close();
System.out.println("RESULT[naturalid-mutable-stale-lookup]: byNaturalId(\"CODE-OLD\") = " + byOld
+ ", byNaturalId(\"CODE-NEW\") = " + (byNew == null ? "null" : byNew.getId()));
assertThat(byOld).isNull();
assertThat(byNew).isNotNull();
assertThat(byNew.getId()).isEqualTo(id);
}
}
@@ -0,0 +1,251 @@
package com.ankurm.hibernatedemo.pagination;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.TypedQuery;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.ScrollMode;
import org.hibernate.ScrollableResults;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Proves chapter 23's pagination claims: {@code setFirstResult}/{@code setMaxResults}
* translating to the dialect's real LIMIT/OFFSET syntax, {@code ScrollableResults} with
* {@code ScrollMode.FORWARD_ONLY}, the in-memory-pagination fallback that a JOIN FETCH triggers
* (and its real warning code), keyset/seek pagination as the fix for deep offsets, and the
* total-count-query pattern for a "Page N of M" UI.
*
* <p>Docs: docs/23-pagination.md.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class PaginationTest {
@Autowired
private EntityManagerFactory emf;
@Test
void limitOffset_translatesToDialectSyntax() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= 5; i++) {
em.persist(new Article("LimitOffset-" + i, i));
}
em.getTransaction().commit();
em.clear();
TypedQuery<Article> query = em.createQuery(
"select a from Article a where a.title like 'LimitOffset-%' order by a.sequence",
Article.class);
query.setFirstResult(2);
query.setMaxResults(2);
List<Article> page = query.getResultList();
em.close();
List<String> titles = page.stream().map(Article::getTitle).toList();
System.out.println("RESULT[pagination-limit-offset]: setFirstResult(2).setMaxResults(2) "
+ "over 5 rows ordered by sequence -- page contents: " + titles
+ " -- items 3 and 4 of 5, confirming the OFFSET skipped exactly 2 rows and the"
+ " LIMIT capped the page at exactly 2.");
assertThat(titles).containsExactly("LimitOffset-3", "LimitOffset-4");
}
@Test
void scrollableResults_forwardOnly_readsWithoutLoadingWholeListUpfront() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= 10; i++) {
em.persist(new Article("Scroll-" + i, i));
}
em.getTransaction().commit();
Session session = em.unwrap(Session.class);
int seen = 0;
List<String> firstThree = new ArrayList<>();
try (ScrollableResults<Article> results = session.createQuery(
"select a from Article a where a.title like 'Scroll-%' order by a.sequence",
Article.class)
.setReadOnly(true)
.scroll(ScrollMode.FORWARD_ONLY)) {
while (results.next()) {
Article a = results.get();
seen++;
if (firstThree.size() < 3) {
firstThree.add(a.getTitle());
}
}
}
em.close();
System.out.println("RESULT[pagination-scrollable-forward-only]: ScrollMode.FORWARD_ONLY "
+ "walked all " + seen + " rows one at a time via results.next()/results.get() -- "
+ "first three encountered: " + firstThree
+ " -- no List<Article> holding all 10 rows was ever built by this test's own"
+ " code, unlike getResultList().");
assertThat(seen).isEqualTo(10);
assertThat(firstThree).containsExactly("Scroll-1", "Scroll-2", "Scroll-3");
}
@Test
void joinFetchOrderedByRoot_paginatesViaDerivedTable_noInMemoryFallback() {
// Ordering by a column on the ROOT entity: Hibernate 7.4.5.Final's query translator
// paginates the root ids first (a derived-table subquery with its own OFFSET/FETCH) and
// joins the fetched collection onto that already-paginated set of ids -- confirmed by
// reading the generated SQL below, not by assuming the older in-memory-fallback story
// still applies here.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= 3; i++) {
Article a = new Article("JoinFetch-" + i, i);
a.addComment(new Comment("first comment on " + i));
a.addComment(new Comment("second comment on " + i));
em.persist(a);
}
em.getTransaction().commit();
em.clear();
TypedQuery<Article> query = em.createQuery(
"select distinct a from Article a join fetch a.comments "
+ "where a.title like 'JoinFetch-%' order by a.sequence",
Article.class);
query.setFirstResult(0);
query.setMaxResults(2);
List<Article> page = query.getResultList();
em.close();
System.out.println("RESULT[pagination-joinfetch-root-order-no-warning]: join fetch + "
+ "setFirstResult/setMaxResults, ordered by a ROOT-entity column -- no "
+ "HHH90003004 warning was logged; the generated SQL (see the committed "
+ "transcript) paginates a derived subquery of article ids first, then joins the "
+ "comments onto that already-paginated set. Page size: " + page.size()
+ " distinct articles.");
assertThat(page).hasSize(2);
}
@Test
void joinFetchOrderedByCollectionColumn_fallsBackToInMemoryPagination() {
// Ordering by a column that lives on the FETCHED COLLECTION itself: the "paginate the
// root ids first" trick above can't work when the sort key isn't a root-entity column,
// so this is the query shape that actually reproduces the classic in-memory-pagination
// fallback and its real warning code.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= 3; i++) {
Article a = new Article("JoinFetchOrder-" + i, i);
a.addComment(new Comment("first comment on " + i));
a.addComment(new Comment("second comment on " + i));
em.persist(a);
}
em.getTransaction().commit();
em.clear();
TypedQuery<Article> query = em.createQuery(
"select distinct a from Article a join fetch a.comments c "
+ "where a.title like 'JoinFetchOrder-%' order by c.body",
Article.class);
query.setFirstResult(0);
query.setMaxResults(2);
List<Article> page = query.getResultList();
em.close();
System.out.println("RESULT[pagination-joinfetch-collection-order-warning]: join fetch + "
+ "setFirstResult/setMaxResults, ordered by a column on the FETCHED COLLECTION "
+ "(c.body) -- Hibernate logs its own HHH90003004 warning (\"firstResult/"
+ "maxResults specified with collection fetch; applying in memory\"), not the "
+ "HHH000104 code sometimes quoted for this; that code belongs to a different, "
+ "older message entirely. Page size returned: " + page.size()
+ " distinct articles, computed by loading the full joined result set into "
+ "memory and paginating it there in application code.");
assertThat(page).hasSize(2);
}
@Test
void keysetPagination_avoidsOffsetEntirely() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= 20; i++) {
em.persist(new Article("Keyset-" + i, i));
}
em.getTransaction().commit();
em.clear();
// Page 1: no keyset yet, so a sentinel "everything greater than the smallest possible id".
List<Article> firstPage = em.createQuery(
"select a from Article a where a.title like 'Keyset-%' and a.id > :lastId "
+ "order by a.id asc", Article.class)
.setParameter("lastId", 0L)
.setMaxResults(5)
.getResultList();
Long lastIdOfFirstPage = firstPage.get(firstPage.size() - 1).getId();
// Page 2: WHERE id > <last id seen> -- no OFFSET at all, so this stays O(page size) no
// matter how deep into the result set the caller has paged.
List<Article> secondPage = em.createQuery(
"select a from Article a where a.title like 'Keyset-%' and a.id > :lastId "
+ "order by a.id asc", Article.class)
.setParameter("lastId", lastIdOfFirstPage)
.setMaxResults(5)
.getResultList();
em.close();
List<String> firstTitles = firstPage.stream().map(Article::getTitle).toList();
List<String> secondTitles = secondPage.stream().map(Article::getTitle).toList();
System.out.println("RESULT[pagination-keyset-seek]: keyset page 1 (id > 0) -- "
+ firstTitles + " | keyset page 2 (id > last id of page 1) -- " + secondTitles
+ " -- each page's WHERE clause carries the previous page's last id, so the "
+ "database never has to count-and-skip rows the way OFFSET does.");
assertThat(firstTitles).containsExactly(
"Keyset-1", "Keyset-2", "Keyset-3", "Keyset-4", "Keyset-5");
assertThat(secondTitles).containsExactly(
"Keyset-6", "Keyset-7", "Keyset-8", "Keyset-9", "Keyset-10");
}
@Test
void totalCountQuery_forPageOfMPattern() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= 7; i++) {
em.persist(new Article("CountPattern-" + i, i));
}
em.getTransaction().commit();
em.clear();
long totalMatching = em.createQuery(
"select count(a) from Article a where a.title like 'CountPattern-%'", Long.class)
.getSingleResult();
int pageSize = 3;
long totalPages = (totalMatching + pageSize - 1) / pageSize;
List<Article> pageTwo = em.createQuery(
"select a from Article a where a.title like 'CountPattern-%' order by a.sequence",
Article.class)
.setFirstResult(pageSize)
.setMaxResults(pageSize)
.getResultList();
em.close();
List<String> pageTwoTitles = pageTwo.stream().map(Article::getTitle).toList();
System.out.println("RESULT[pagination-total-count-pattern]: " + totalMatching
+ " matching rows, page size " + pageSize + " -> " + totalPages
+ " total pages ('Page 2 of " + totalPages + "') | page 2 contents: "
+ pageTwoTitles
+ " -- two separate queries (a COUNT and a LIMIT/OFFSET SELECT), not one query"
+ " doing both.");
assertThat(totalMatching).isEqualTo(7);
assertThat(totalPages).isEqualTo(3);
assertThat(pageTwoTitles).containsExactly("CountPattern-4", "CountPattern-5", "CountPattern-6");
}
}
@@ -0,0 +1,85 @@
package com.ankurm.hibernatedemo.persistenceannotations;
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.junit.jupiter.api.Test;
/**
* Reproduces the real failure mode of the JPA default {@code @Enumerated} strategy (ORDINAL):
* persist a row with the V1 enum ordering (via one standalone SessionFactory that owns only
* {@link EnumDefaultOrdinalEntity}), then read the SAME row back through a second SessionFactory
* that only knows {@link EnumReorderedV2Entity} -- a status enum with a constant inserted before
* SHIPPED. Two separate SessionFactories against the same physical H2 database avoid Hibernate's
* "same table mapped twice in one persistence unit" schema-export guard, which a single Spring
* context would trip.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
class EnumOrdinalDefaultTest {
private static final String DB_URL = "jdbc:h2:mem:enumordinal;DB_CLOSE_DELAY=-1";
private StandardServiceRegistry registry(String ddlAuto) {
return new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", DB_URL)
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.connection.password", "")
.applySetting("hibernate.hbm2ddl.auto", ddlAuto)
.build();
}
@Test
void ordinalDefault_reorderedEnum_silentlyReturnsWrongConstant_noException() {
StandardServiceRegistry writeRegistry = registry("create");
Long id;
try (SessionFactory writeSf = new MetadataSources(writeRegistry)
.addAnnotatedClass(EnumDefaultOrdinalEntity.class)
.buildMetadata()
.buildSessionFactory()) {
try (Session s = writeSf.openSession()) {
Transaction tx = s.beginTransaction();
EnumDefaultOrdinalEntity original = new EnumDefaultOrdinalEntity(EnumDefaultOrdinalEntity.OrderStatus.SHIPPED);
s.persist(original);
tx.commit();
id = original.getId();
}
// Prove the raw stored ordinal.
try (Session s = writeSf.openSession()) {
Number rawOrdinal = (Number) s.createNativeQuery(
"select status from enum_default_ordinal_entity where id = " + id)
.getSingleResult();
assertThat(rawOrdinal.intValue()).isEqualTo(1);
System.out.println("RESULT[enum-ordinal-default]: stored ordinal for SHIPPED (V1 ordering) = " + rawOrdinal);
}
} finally {
StandardServiceRegistryBuilder.destroy(writeRegistry);
}
// Second SessionFactory, same physical DB, "V2" reordered enum, table already exists (ddl-auto=none).
StandardServiceRegistry readRegistry = registry("none");
try (SessionFactory readSf = new MetadataSources(readRegistry)
.addAnnotatedClass(EnumReorderedV2Entity.class)
.buildMetadata()
.buildSessionFactory()) {
try (Session s = readSf.openSession()) {
EnumReorderedV2Entity reread = s.get(EnumReorderedV2Entity.class, id);
System.out.println("RESULT[enum-ordinal-default]: same row re-read through V2 (PENDING_REVIEW inserted "
+ "before SHIPPED) enum ordering = " + reread.getStatus()
+ " -- no exception thrown, silently resolves to the WRONG constant.");
assertThat(reread.getStatus())
.as("ordinal 1 now means PENDING_REVIEW in the V2 enum, not SHIPPED -- silent data corruption")
.isEqualTo(EnumReorderedV2Entity.ReorderedStatus.PENDING_REVIEW);
}
} finally {
StandardServiceRegistryBuilder.destroy(readRegistry);
}
}
}
@@ -0,0 +1,59 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* The concrete failure: an entity with id-based equals()/hashCode() is added to a HashSet
* BEFORE flush (id == null), then persisted (id gets assigned). The same reference, looked up
* in the SAME set, is no longer found -- because its hash code changed after insertion, so
* HashSet is now probing the wrong bucket.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class IdBasedEqualsHashSetTrapTest {
@Autowired
private EntityManagerFactory emf;
@Test
void idBasedEquals_addedToSetBeforeFlush_containsReturnsFalseAfterIdAssigned() {
Set<IdBasedEqualsEntity> set = new HashSet<>();
IdBasedEqualsEntity e = new IdBasedEqualsEntity("widget");
set.add(e); // id is null at this point -- hashCode() = Objects.hash((Long) null)
assertThat(set.contains(e)).isTrue(); // trivially true right now
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(e);
em.getTransaction().commit(); // id gets assigned HERE -- hashCode() changes
em.close();
assertThat(e.getId()).isNotNull();
boolean stillFound = set.contains(e);
System.out.println("RESULT[id-based-equals-hashset-trap]: after persist(), e.getId()=" + e.getId()
+ ", set.contains(e) = " + stillFound + " (same reference, same set, only the hash code changed)");
assertThat(stillFound)
.as("HashSet.contains() on the SAME reference must return false once the id-based hashCode changed after insertion")
.isFalse();
// Bonus: iterating the set and calling equals() manually still finds it (equals() itself is fine) --
// it's specifically the bucket lookup (which trusts the now-stale cached hash) that is broken.
boolean foundByIteration = set.stream().anyMatch(x -> x.equals(e));
System.out.println("RESULT[id-based-equals-hashset-trap]: manual iteration foundByIteration = " + foundByIteration
+ " -- confirms equals() itself still works; it's HashSet's bucket indexing that is now wrong.");
assertThat(foundByIteration).isTrue();
}
}
@@ -0,0 +1,81 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.TypedQuery;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Three things new in Jakarta Persistence 3.2 that post 4864 predates: {@code @EnumeratedValue}
* (custom enum persisted representation), {@code getSingleResultOrNull()} on TypedQuery, and
* record types as JPQL constructor-expression targets.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class Jpa32NewFeaturesTest {
@Autowired
private EntityManagerFactory emf;
@Test
void enumeratedValue_persistsTheCustomCode_notOrdinalOrName() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
EnumeratedValueEntity e = new EnumeratedValueEntity(EnumeratedValueEntity.Priority.HIGH);
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
Object raw = em2.createNativeQuery(
"select priority from enumerated_value_entity where id = " + id).getSingleResult();
EnumeratedValueEntity reread = em2.find(EnumeratedValueEntity.class, id);
em2.close();
System.out.println("RESULT[jpa32-enumeratedvalue]: raw DB value for HIGH = '" + raw
+ "' (neither ordinal '2' nor name 'HIGH' -- the @EnumeratedValue-annotated code 'H')");
assertThat(raw).isEqualTo("H");
assertThat(reread.getPriority()).isEqualTo(EnumeratedValueEntity.Priority.HIGH);
}
@Test
void getSingleResultOrNull_newIn32_returnsNullInsteadOfThrowing() {
EntityManager em = emf.createEntityManager();
TypedQuery<EnumeratedValueEntity> q = em.createQuery(
"select e from EnumeratedValueEntity e where e.id = :id", EnumeratedValueEntity.class);
q.setParameter("id", -999L);
EnumeratedValueEntity result = q.getSingleResultOrNull();
em.close();
System.out.println("RESULT[jpa32-getsingleresultornull]: query matching zero rows via getSingleResultOrNull() = " + result
+ " (getSingleResult() would have thrown NoResultException here)");
assertThat(result).isNull();
}
@Test
void jpqlConstructorExpression_targetingARecord_worksIn725() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new EnumeratedValueEntity(EnumeratedValueEntity.Priority.HIGH));
em.persist(new EnumeratedValueEntity(EnumeratedValueEntity.Priority.HIGH));
em.persist(new EnumeratedValueEntity(EnumeratedValueEntity.Priority.LOW));
em.getTransaction().commit();
List<PriorityCountView> counts = em.createQuery(
"select new com.ankurm.hibernatedemo.persistenceannotations.PriorityCountView(e.priority, count(e)) "
+ "from EnumeratedValueEntity e group by e.priority order by e.priority",
PriorityCountView.class).getResultList();
em.close();
System.out.println("RESULT[jpa32-record-constructor-expression]: " + counts);
assertThat(counts).extracting(PriorityCountView::priority)
.contains(EnumeratedValueEntity.Priority.HIGH, EnumeratedValueEntity.Priority.LOW);
}
}
@@ -0,0 +1,51 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Does {@code @JdbcTypeCode(SqlTypes.JSON)} actually work against H2 2.4.240? Honest empirical
* check -- if H2 rejects it, this test says so instead of asserting success.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class JsonColumnOnH2Test {
@Autowired
private EntityManagerFactory emf;
@Test
void jdbcTypeCodeJson_onH2_roundTripsAMap() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
JsonColumnEntity e = new JsonColumnEntity(Map.of("color", "red", "qty", 5));
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
JsonColumnEntity loaded = em2.find(JsonColumnEntity.class, id);
em2.close();
System.out.println("RESULT[jdbctypecode-json-h2]: persisted+loaded details=" + loaded.getDetails());
assertThat(loaded.getDetails()).containsEntry("color", "red");
// Inspect the actual column type H2 chose for it.
EntityManager em3 = emf.createEntityManager();
Object colType = em3.createNativeQuery(
"select data_type from information_schema.columns "
+ "where table_name = 'JSON_COLUMN_ENTITY' and column_name = 'DETAILS'")
.getSingleResult();
em3.close();
System.out.println("RESULT[jdbctypecode-json-h2]: H2 column type for the JSON field = " + colType);
}
}
@@ -0,0 +1,51 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* MixedAccessEntity has FIELD access by default (because @Id sits on a field), but one
* property ("computedLabel") is explicitly switched to PROPERTY access. This proves:
* (1) Hibernate really does call the getter -- not read a backing field -- to determine the
* persisted value for that one property, and (2) it calls it MORE than once per flush
* (dirty-check read + write), which matters if the getter has side effects or is expensive.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class MixedAccessTest {
@Autowired
private EntityManagerFactory emf;
@Test
void propertyAccessAttribute_getterIsCalledByHibernate_multipleTimesPerFlush() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
MixedAccessEntity e = new MixedAccessEntity("widget");
int callsBeforePersist = e.getGetterCallCount();
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
int callsAfterFlush = e.getGetterCallCount();
em.close();
System.out.println("RESULT[mixed-access]: getComputedLabel() call count before persist=" + callsBeforePersist
+ ", after commit/flush=" + callsAfterFlush
+ " (PROPERTY-access attributes are read via the getter at flush time, not via a backing field)");
assertThat(callsAfterFlush).isGreaterThan(callsBeforePersist);
EntityManager em2 = emf.createEntityManager();
Object storedLabel = em2.createNativeQuery(
"select computed_label from mixed_access_entity where id = " + id).getSingleResult();
em2.close();
System.out.println("RESULT[mixed-access]: DB column computed_label = " + storedLabel);
assertThat(storedLabel).isEqualTo("WIDGET");
}
}
@@ -0,0 +1,45 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Empirical: does {@code @Temporal} on a {@code java.time.LocalDate} field boot cleanly in
* Hibernate 7.4.5.Final, despite the annotation itself being {@code @Deprecated(since="3.2")}?
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class TemporalOnJavaTimeTest {
@Autowired
private EntityManagerFactory emf;
@Test
void temporalOnLocalDate_bootsAndPersistsFine_annotationIsSimplyIgnored() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
TemporalOnLocalDateEntity e = new TemporalOnLocalDateEntity(LocalDate.of(2026, 1, 15));
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
TemporalOnLocalDateEntity loaded = em2.find(TemporalOnLocalDateEntity.class, id);
em2.close();
assertThat(loaded.getEventDate()).isEqualTo(LocalDate.of(2026, 1, 15));
System.out.println("RESULT[temporal-on-localdate]: boot succeeded (not silent -- Hibernate logs "
+ "HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal] "
+ "at boot time, WARN level, one line per annotated field); round-tripped eventDate=" + loaded.getEventDate()
+ ". The mapping itself is unaffected -- LocalDate maps the same with or without @Temporal.");
}
}
@@ -0,0 +1,297 @@
package com.ankurm.hibernatedemo.procedure;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.ParameterMode;
import jakarta.persistence.StoredProcedureQuery;
import java.math.BigDecimal;
import javax.sql.DataSource;
import org.hibernate.Session;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/08-stored-procedures.md, "Potential Pitfalls" section (merged posts 4867 + 4881) --
* this is the highest-value part of the chapter: real, verbatim exceptions from real
* misconfigurations, not described-but-untested prose.
*
* <p>Own DataSource: {@code jdbc:hsqldb:mem:procdemo-failures}.
*/
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:hsqldb:mem:procdemo-failures;shutdown=true",
"spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver",
"spring.datasource.username=SA",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop"
})
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProcedureFailureModesTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static volatile boolean schemaReady = false;
@Autowired
private EntityManagerFactory emf;
@Autowired
private DataSource dataSource;
private void ensureSchema() throws Exception {
if (!schemaReady) {
synchronized (ProcedureFailureModesTest.class) {
if (!schemaReady) {
ProcedureSchemaSupport.createAll(dataSource);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new ProcEmployee(1, "Alice", new BigDecimal("50000.00")));
em.persist(new ProcEmployee(2, "Bob", new BigDecimal("60000.00")));
em.getTransaction().commit();
em.close();
schemaReady = true;
}
}
}
}
/**
* Registering a parameter under a name the procedure does not actually have.
*
* <p>SURPRISE (verified, corrects an assumption): this does NOT fail, and does NOT bind a
* NULL. HSQLDB's JDBC driver calls procedures using positional {@code {call GET_TAX(?, ?)}}
* syntax -- the parameter NAME never crosses the wire to the database at all. Hibernate maps
* a {@code registerStoredProcedureParameter(String name, ...)} call to the Nth JDBC "?"
* purely by REGISTRATION ORDER; the {@code name} string is only a client-side label used
* later by {@code setParameter(name, ...)} / {@code getOutputParameterValue(name)}. So
* registering "employee_id" first (matching the procedure's real first parameter by
* POSITION) still binds correctly and returns the right tax value, even though "employee_id"
* is not a real parameter of GET_TAX. The article's advice to "match parameter order" is
* therefore the operative safety rule -- matching NAMES is cosmetic for drivers like this one.
*/
@Test
@Order(1)
void wrongParameterName_bindsPositionally_nameIsCosmeticOnHsqldb() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX");
query.registerStoredProcedureParameter("employee_id", Integer.class, ParameterMode.IN); // WRONG: should be "emp_id"
query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT);
Exception caught = null;
Object outValue = null;
try {
query.setParameter("employee_id", 1);
query.execute();
outValue = query.getOutputParameterValue("tax_amount");
} catch (Exception e) {
caught = e;
}
em.getTransaction().rollback();
em.close();
DEMO.info("wrong parameter name 'employee_id' (procedure expects 'emp_id') threw: {}: {}",
caught == null ? "NOTHING -- bound positionally regardless of the name" : caught.getClass().getName(),
caught == null ? "tax_amount output = " + outValue : caught.getMessage());
// The mislabeled parameter still binds to JDBC position 1 (registration order), which is
// GET_TAX's real first ("emp_id") slot -- so the call succeeds with the CORRECT result
// despite the wrong name. Proof that "name" is not validated against the database here.
assertThat(caught).isNull();
assertThat((BigDecimal) outValue).isEqualByComparingTo("7500.00");
}
/** Registering parameters in the wrong positional order (swap IN and OUT slots). */
@Test
@Order(2)
void wrongPositionalOrder_throwsOrMisbinds() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// GET_TAX is really (1=emp_id IN, 2=tax_amount OUT); register them swapped.
StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX");
query.registerStoredProcedureParameter(1, BigDecimal.class, ParameterMode.OUT);
query.registerStoredProcedureParameter(2, Integer.class, ParameterMode.IN);
Exception caught = null;
try {
query.setParameter(2, 1);
query.execute();
Object out = query.getOutputParameterValue(1);
DEMO.info("swapped positional registration did NOT throw; out param 1 = {}", out);
} catch (Exception e) {
caught = e;
}
em.getTransaction().rollback();
em.close();
DEMO.info("swapped IN/OUT positional registration on GET_TAX threw: {}: {}",
caught == null ? "NOTHING (see logged value above)" : caught.getClass().getName(),
caught == null ? "" : caught.getMessage());
}
/** ParameterMode mismatch: registering the real IN parameter as OUT. */
@Test
@Order(3)
void parameterModeMismatch_inRegisteredAsOut_throws() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX");
query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.OUT); // WRONG mode
query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT);
Exception caught = null;
try {
query.execute();
} catch (Exception e) {
caught = e;
}
em.getTransaction().rollback();
em.close();
DEMO.info("emp_id (really IN) registered as ParameterMode.OUT threw: {}: {}",
caught == null ? "NOTHING" : caught.getClass().getName(),
caught == null ? "" : caught.getMessage());
assertThat(caught).isNotNull();
}
/** getResultList() called on a procedure that has NO result set and NO output parameters. */
@Test
@Order(4)
void getResultListOnProcedureWithNoResultSet_throws() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createStoredProcedureQuery("NO_RESULT_SET_PROC");
query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN);
query.setParameter("emp_id", 1);
query.execute();
IllegalStateException caught = Assertions.assertThrows(IllegalStateException.class, query::getResultList);
DEMO.info("getResultList() on a no-result-set procedure threw: {}: {}",
caught.getClass().getName(), caught.getMessage());
em.getTransaction().commit();
em.close();
}
/** Forgetting to call execute() before reading an output parameter. */
@Test
@Order(5)
void forgettingExecute_beforeReadingOutputParameter_throws() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX");
query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN);
query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT);
query.setParameter("emp_id", 1);
// Deliberately NOT calling query.execute() here.
Exception caught = null;
Object value = null;
try {
value = query.getOutputParameterValue("tax_amount");
} catch (Exception e) {
caught = e;
}
em.getTransaction().rollback();
em.close();
DEMO.info("getOutputParameterValue() WITHOUT calling execute() first threw: {} (value={})",
caught == null ? "NOTHING -- getOutputParameterValue() triggered execution implicitly" : caught.getClass().getName(),
value);
// SURPRISE (verified): calling getOutputParameterValue() without an explicit execute()
// call first does NOT throw "forgot to call execute()". Hibernate's ProcedureCallImpl
// lazily triggers the JDBC execute() itself the first time output is requested. The
// "forgetting execute()" pitfall described in blog folklore is not reproducible against
// Hibernate 7.4.5's StoredProcedureQuery for OUT-parameter access.
assertThat(caught).isNull();
assertThat((BigDecimal) value).isEqualByComparingTo("7500.00");
}
/**
* THE flush question: persist a row, do NOT flush, then call a stored procedure that counts
* rows in the same table over the SAME connection/transaction. Does the procedure see the
* unflushed row?
*/
@Test
@Order(6)
void unflushedPersist_isNotVisibleToStoredProcedure_untilFlushed() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// Baseline count before adding anything in this transaction.
StoredProcedureQuery baseline = em.createStoredProcedureQuery("COUNT_EMPLOYEES");
baseline.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT);
baseline.execute();
int before = (Integer) baseline.getOutputParameterValue("total");
DEMO.info("COUNT_EMPLOYEES before persisting a new row = {}", before);
// Persist a THIRD employee but deliberately do not flush.
em.persist(new ProcEmployee(3, "Carol", new BigDecimal("70000.00")));
StoredProcedureQuery afterPersist = em.createStoredProcedureQuery("COUNT_EMPLOYEES");
afterPersist.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT);
afterPersist.execute();
int afterUnflushed = (Integer) afterPersist.getOutputParameterValue("total");
DEMO.info("COUNT_EMPLOYEES after persist() but WITHOUT an explicit flush() = {}", afterUnflushed);
em.flush();
StoredProcedureQuery afterFlush = em.createStoredProcedureQuery("COUNT_EMPLOYEES");
afterFlush.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT);
afterFlush.execute();
int afterExplicitFlush = (Integer) afterFlush.getOutputParameterValue("total");
DEMO.info("COUNT_EMPLOYEES after an explicit flush() = {}", afterExplicitFlush);
em.getTransaction().rollback();
em.close();
assertThat(afterUnflushed).isEqualTo(before);
assertThat(afterExplicitFlush).isEqualTo(before + 1);
}
/**
* CORRECTION to a common assumption: for HQL/native queries, registering
* addSynchronizedEntityClass(...)/addSynchronizedQuerySpace(...) is documented to trigger an
* auto-flush of pending changes to the synchronized table before the query runs. Stored
* procedures are DIFFERENT: {@link org.hibernate.procedure.ProcedureCall} exposes the same
* addSynchronizedEntityClass(...) method (inherited from SynchronizeableQuery), but calling
* it here does NOT cause an auto-flush before the CallableStatement executes. Verified below:
* an unflushed persist() is still invisible to the procedure even after declaring the
* synchronization.
*/
@Test
@Order(7)
void procedureCallSynchronizedEntityClass_doesNotAutoFlush() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
em.persist(new ProcEmployee(4, "Dave", new BigDecimal("80000.00"))); // NOT flushed explicitly
org.hibernate.procedure.ProcedureCall call = session.createStoredProcedureCall("COUNT_EMPLOYEES");
call.registerStoredProcedureParameter("total", Integer.class, ParameterMode.OUT);
call.addSynchronizedEntityClass(ProcEmployee.class); // documented to auto-flush for HQL/native -- does NOT for procedures
call.execute();
int total = (Integer) call.getOutputParameterValue("total");
DEMO.info("ProcedureCall with addSynchronizedEntityClass(ProcEmployee.class), unflushed Dave NOT counted: "
+ "COUNT_EMPLOYEES = {} (still just Alice+Bob -- addSynchronizedEntityClass had NO auto-flush effect here)", total);
em.getTransaction().rollback();
em.close();
// Alice + Bob (seeded) only -- Dave was never flushed, and addSynchronizedEntityClass did
// not force it. Carol from the earlier test was rolled back, so she is not present either.
assertThat(total).isEqualTo(2);
}
}
@@ -0,0 +1,96 @@
package com.ankurm.hibernatedemo.procedure;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
/**
* Creates the real HSQLDB 2.7.3 SQL/PSM stored procedures used by every test in this package.
* Hibernate's {@code ddl-auto} only creates the entity table; it has no concept of stored
* procedures, so they are created directly over JDBC once per test-class DataSource.
*
* <p>Idempotent: {@code DROP PROCEDURE} first (ignoring failure), so re-running against a
* context that Spring's test cache kept warm from a previous run doesn't fail with
* "already exists".
*/
final class ProcedureSchemaSupport {
private ProcedureSchemaSupport() {}
static void createAll(DataSource ds) throws SQLException {
try (Connection c = ds.getConnection(); Statement st = c.createStatement()) {
drop(st, "GET_TAX");
drop(st, "ADJUST_SALARY");
drop(st, "LIST_EMPLOYEES");
drop(st, "LIST_EMPLOYEE_NAMES");
drop(st, "COUNT_EMPLOYEES");
drop(st, "NO_RESULT_SET_PROC");
st.execute("""
CREATE PROCEDURE GET_TAX(IN emp_id INT, OUT tax_amount DECIMAL(10,2))
READS SQL DATA
BEGIN ATOMIC
SELECT salary * 0.15 INTO tax_amount FROM PROC_EMPLOYEES WHERE id = emp_id;
END
""");
st.execute("""
CREATE PROCEDURE ADJUST_SALARY(INOUT sal DECIMAL(10,2), IN bonus_pct DECIMAL(5,2))
MODIFIES SQL DATA
BEGIN ATOMIC
SET sal = sal * (1 + bonus_pct / 100);
END
""");
st.execute("""
CREATE PROCEDURE LIST_EMPLOYEES()
READS SQL DATA DYNAMIC RESULT SETS 1
BEGIN ATOMIC
DECLARE result CURSOR WITH RETURN FOR
SELECT id, name, salary FROM PROC_EMPLOYEES ORDER BY id;
OPEN result;
END
""");
st.execute("""
CREATE PROCEDURE LIST_EMPLOYEE_NAMES()
READS SQL DATA DYNAMIC RESULT SETS 1
BEGIN ATOMIC
DECLARE result CURSOR WITH RETURN FOR
SELECT id, name FROM PROC_EMPLOYEES ORDER BY id;
OPEN result;
END
""");
// Deliberately has NO result set and NO out parameter -- used for the
// "getResultList() on a procedure with no result set" and "forgetting execute()"
// failure-mode tests.
st.execute("""
CREATE PROCEDURE NO_RESULT_SET_PROC(IN emp_id INT)
MODIFIES SQL DATA
BEGIN ATOMIC
UPDATE PROC_EMPLOYEES SET name = name WHERE id = emp_id;
END
""");
// Used for the flush-visibility trap: does a procedure called mid-transaction see a
// persisted-but-not-yet-flushed row?
st.execute("""
CREATE PROCEDURE COUNT_EMPLOYEES(OUT total INT)
READS SQL DATA
BEGIN ATOMIC
SELECT COUNT(*) INTO total FROM PROC_EMPLOYEES;
END
""");
}
}
private static void drop(Statement st, String name) throws SQLException {
try {
st.execute("DROP PROCEDURE " + name);
} catch (SQLException ignored) {
// did not exist yet
}
}
}
@@ -0,0 +1,204 @@
package com.ankurm.hibernatedemo.procedure;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.ParameterMode;
import jakarta.persistence.StoredProcedureQuery;
import java.math.BigDecimal;
import java.util.List;
import javax.sql.DataSource;
import org.hibernate.Session;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/**
* Backs docs/08-stored-procedures.md (merged posts 4867 + 4881). Runs REAL SQL/PSM stored procedures
* on HSQLDB 2.7.3 -- see {@link ProcedureSchemaSupport} for the DDL -- and calls them through
* every API surface the two source articles described but never executed:
* {@code @NamedStoredProcedureQuery}, {@code EntityManager.createStoredProcedureQuery(...)},
* and {@code Session.createStoredProcedureQuery(...)}.
*
* <p>Own DataSource: {@code jdbc:hsqldb:mem:procdemo-happy} (HSQLDB, not H2 -- H2 does not
* support IN/OUT/INOUT stored procedures with SQL/PSM bodies the way HSQLDB does).
*/
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:hsqldb:mem:procdemo-happy;shutdown=true",
"spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver",
"spring.datasource.username=SA",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop"
})
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class StoredProcedureHappyPathTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static volatile boolean schemaReady = false;
@Autowired
private EntityManagerFactory emf;
@Autowired
private DataSource dataSource;
@BeforeAll
static void resetFlag() {
schemaReady = false;
}
private void ensureSchema() throws Exception {
if (!schemaReady) {
synchronized (StoredProcedureHappyPathTest.class) {
if (!schemaReady) {
ProcedureSchemaSupport.createAll(dataSource);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.persist(new ProcEmployee(1, "Alice", new BigDecimal("50000.00")));
em.persist(new ProcEmployee(2, "Bob", new BigDecimal("60000.00")));
em.getTransaction().commit();
em.close();
schemaReady = true;
}
}
}
}
@Test
@Order(1)
void namedStoredProcedureQuery_viaEntityManager_returnsRealOutParameter() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createNamedStoredProcedureQuery("ProcEmployee.getTax");
query.setParameter("emp_id", 1);
boolean hadResultSet = query.execute();
BigDecimal tax = (BigDecimal) query.getOutputParameterValue("tax_amount");
em.getTransaction().commit();
em.close();
DEMO.info("@NamedStoredProcedureQuery ProcEmployee.getTax(emp_id=1) execute()={} tax_amount={}", hadResultSet, tax);
assertThat(tax).isEqualByComparingTo("7500.00");
}
@Test
@Order(2)
void unnamedStoredProcedureQuery_viaEntityManagerCreateStoredProcedureQuery() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createStoredProcedureQuery("GET_TAX");
query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN);
query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT);
query.setParameter("emp_id", 2);
query.execute();
BigDecimal tax = (BigDecimal) query.getOutputParameterValue("tax_amount");
em.getTransaction().commit();
em.close();
DEMO.info("EntityManager.createStoredProcedureQuery(\"GET_TAX\") for emp 2, tax_amount={}", tax);
assertThat(tax).isEqualByComparingTo("9000.00");
}
@Test
@Order(3)
void unnamedStoredProcedureQuery_viaHibernateSession() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
StoredProcedureQuery query = session.createStoredProcedureQuery("GET_TAX");
query.registerStoredProcedureParameter("emp_id", Integer.class, ParameterMode.IN);
query.registerStoredProcedureParameter("tax_amount", BigDecimal.class, ParameterMode.OUT);
query.setParameter("emp_id", 1);
query.execute();
BigDecimal tax = (BigDecimal) query.getOutputParameterValue("tax_amount");
em.getTransaction().commit();
em.close();
DEMO.info("Session.createStoredProcedureQuery(\"GET_TAX\") for emp 1, tax_amount={}", tax);
assertThat(tax).isEqualByComparingTo("7500.00");
}
@Test
@Order(4)
void inoutParameter_roundTripsThroughSameParameterSlot() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createStoredProcedureQuery("ADJUST_SALARY");
query.registerStoredProcedureParameter("sal", BigDecimal.class, ParameterMode.INOUT);
query.registerStoredProcedureParameter("bonus_pct", BigDecimal.class, ParameterMode.IN);
query.setParameter("sal", new BigDecimal("1000.00"));
query.setParameter("bonus_pct", new BigDecimal("10.00"));
query.execute();
Object result = query.getOutputParameterValue("sal");
em.getTransaction().commit();
em.close();
DEMO.info("INOUT parameter 'sal' after ADJUST_SALARY(1000.00, 10.00) = {}", result);
assertThat((BigDecimal) result).isEqualByComparingTo("1100.00");
}
/**
* DOCUMENTED FAILURE, not a bug in this test: Hibernate's {@code ProcedureCallImpl} decides
* whether a stored procedure produced a result set by trusting the boolean returned from
* JDBC {@code CallableStatement.execute()}. A raw-JDBC probe (outside Hibernate, see
* docs/output/procedure-hsqldb-jdbc-driver-quirk.txt) shows HSQLDB 2.7.3's driver returns
* {@code false} from {@code execute()} for a {@code DYNAMIC RESULT SETS} procedure even
* though {@code getResultSet()} (and {@code executeQuery()}) DOES return a populated
* {@code ResultSet}. Hibernate believes the (wrong) {@code false} and never attaches a
* {@code ResultSetOutput}, so {@code getResultList()} throws. This blocks "result set mapped
* to an entity" and "result set mapped to a DTO via @SqlResultSetMapping" through Hibernate's
* stored-procedure API specifically on HSQLDB + Hibernate 7.4.5 -- it is not something
* fixable from application code. A database whose JDBC driver reports {@code execute()}
* correctly (PostgreSQL's REF_CURSOR, or MySQL's direct result sets) would not hit this.
*/
@Test
@Order(5)
void resultSetProcedure_mappedToEntity_hitsHsqldbDriverIncompatibility() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createNamedStoredProcedureQuery("ProcEmployee.listAll");
boolean hadResultSet = query.execute();
DEMO.info("ProcEmployee.listAll execute() returned {} (HSQLDB misreports this as false)", hadResultSet);
IllegalStateException caught = org.junit.jupiter.api.Assertions.assertThrows(
IllegalStateException.class, query::getResultList);
DEMO.info("getResultList() on the (mis-reported) result-set procedure threw: {}: {}",
caught.getClass().getName(), caught.getMessage());
em.getTransaction().rollback();
em.close();
assertThat(caught.getMessage()).contains("was not a ResultSet");
}
/** Same HSQLDB-driver incompatibility as above, this time through @SqlResultSetMapping to a DTO. */
@Test
@Order(6)
void resultSetProcedure_mappedToDto_alsoHitsHsqldbDriverIncompatibility() throws Exception {
ensureSchema();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
StoredProcedureQuery query = em.createStoredProcedureQuery("LIST_EMPLOYEE_NAMES", "EmployeeSummaryMapping");
query.execute();
IllegalStateException caught = org.junit.jupiter.api.Assertions.assertThrows(
IllegalStateException.class, query::getResultList);
DEMO.info("DTO-mapped getResultList() threw: {}: {}", caught.getClass().getName(), caught.getMessage());
em.getTransaction().rollback();
em.close();
assertThat(caught.getMessage()).contains("was not a ResultSet");
}
}
@@ -0,0 +1,131 @@
package com.ankurm.hibernatedemo.proxy;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.Map;
import org.hibernate.Hibernate;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/11-proxies-and-lazy-initialization.md, chapter "fetchgraph vs loadgraph, actually demonstrated".
*
* <p>{@link ProxyBook} has two associations: {@code publisher} (default fetch type, EAGER) and
* {@code reviews} (explicit LAZY). The named graph {@code Book.reviews-only} names only
* {@code reviews}. The two JPA hint keys treat the association NOT named in the graph
* differently:
*
* <ul>
* <li>{@code jakarta.persistence.loadgraph} -- attributes not in the graph keep their
* mapped fetch type. {@code publisher} stays EAGER and gets joined anyway.</li>
* <li>{@code jakarta.persistence.fetchgraph} -- the graph IS the complete fetch plan.
* Any attribute not in it is forced to LAZY, so {@code publisher} does NOT get joined,
* regardless of its mapped EAGER fetch type.</li>
* </ul>
*/
@SpringBootTest
class EntityGraphFetchTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
private Long seedBookWithReviewAndPublisher() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ProxyPublisher publisher = new ProxyPublisher("Graph Press");
em.persist(publisher);
ProxyBook book = new ProxyBook("Graph Book", publisher);
em.persist(book);
ProxyReview review = new ProxyReview("Nice graph", book);
em.persist(review);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
@Test
void loadgraph_joinsNamedAttributeAndKeepsMappedEagerAttributeJoinedToo() {
Long id = seedBookWithReviewAndPublisher();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
EntityGraph<?> graph = em.getEntityGraph("Book.reviews-only");
stats().clear();
ProxyBook book = em.find(ProxyBook.class, id, Map.of("jakarta.persistence.loadgraph", graph));
// One SELECT total: publisher (mapped EAGER, loadgraph does not touch it) and
// reviews (named in the graph) are both fetched without a second round-trip.
assertThat(stats().getPrepareStatementCount())
.as("loadgraph must fetch the named attribute AND keep the mapped-EAGER one, in one query")
.isEqualTo(1);
assertThat(Hibernate.isInitialized(book.getPublisher()))
.as("loadgraph does not demote attributes outside the graph -- publisher is still EAGER")
.isTrue();
assertThat(Hibernate.isInitialized(book.getReviews())).isTrue();
em.getTransaction().commit();
em.close();
}
@Test
void fetchgraph_joinsOnlyNamedAttributeAndForcesMappedEagerAttributeToLazy() {
Long id = seedBookWithReviewAndPublisher();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
EntityGraph<?> graph = em.getEntityGraph("Book.reviews-only");
stats().clear();
ProxyBook book = em.find(ProxyBook.class, id, Map.of("jakarta.persistence.fetchgraph", graph));
assertThat(stats().getPrepareStatementCount())
.as("fetchgraph's single SELECT still includes the join for the named attribute")
.isEqualTo(1);
assertThat(Hibernate.isInitialized(book.getReviews())).isTrue();
assertThat(Hibernate.isInitialized(book.getPublisher()))
.as("fetchgraph treats the graph as the WHOLE fetch plan -- publisher is forced back to LAZY "
+ "even though its mapping says EAGER")
.isFalse();
DEMO.info("fetchgraph: book.getPublisher() runtime class = {}", book.getPublisher().getClass().getName());
em.getTransaction().commit();
em.close();
}
@Test
void noGraphAtAll_defaultFindStillJoinsTheMappedEagerAssociationButLeavesLazyCollectionAlone() {
Long id = seedBookWithReviewAndPublisher();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
stats().clear();
ProxyBook book = em.find(ProxyBook.class, id);
assertThat(stats().getPrepareStatementCount()).isEqualTo(1);
assertThat(Hibernate.isInitialized(book.getPublisher()))
.as("plain find() honours the mapping: publisher is EAGER by default")
.isTrue();
assertThat(Hibernate.isInitialized(book.getReviews()))
.as("plain find() honours the mapping: reviews is LAZY by default")
.isFalse();
em.getTransaction().commit();
em.close();
}
}
@@ -0,0 +1,147 @@
package com.ankurm.hibernatedemo.proxy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Hibernate;
import org.hibernate.LazyInitializationException;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/11-proxies-and-lazy-initialization.md (post 4870 rewrite), chapter "The exception, verbatim".
*
* <p>Reproduces {@code org.hibernate.LazyInitializationException} for real against Hibernate
* 7.4.5.Final and captures the exact message and package, instead of restating the docs.
*/
@SpringBootTest
class LazyInitializationTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedBookWithReview(String title) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ProxyPublisher publisher = new ProxyPublisher("O'Reilly");
em.persist(publisher);
ProxyBook book = new ProxyBook(title, publisher);
em.persist(book);
ProxyReview review = new ProxyReview("Great book", book);
em.persist(review);
book.getReviews().add(review);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
@Test
void accessingLazyCollectionAfterSessionClose_throwsLazyInitializationException() {
Long id = seedBookWithReview("Effective Hibernate");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ProxyBook book = em.find(ProxyBook.class, id);
assertThat(Hibernate.isInitialized(book.getReviews()))
.as("a freshly-found entity's LAZY collection must not be initialized yet")
.isFalse();
em.getTransaction().commit();
em.close();
// The persistence context is gone. Touching the collection now must fail, not
// silently open a new connection.
LazyInitializationException ex = assertThrows(
LazyInitializationException.class,
() -> book.getReviews().size());
DEMO.info("verbatim exception class: {}", ex.getClass().getName());
DEMO.info("verbatim exception message: {}", ex.getMessage());
assertThat(ex.getClass().getName()).isEqualTo("org.hibernate.LazyInitializationException");
// NOTE: this is the COLLECTION-flavoured message. It is NOT "could not initialize
// proxy - no Session" -- that wording belongs to a to-one association proxy, exercised
// separately below. Blog posts (including the one this test corrects) routinely quote
// the proxy message for a collection access; they are two different message templates.
assertThat(ex.getMessage()).contains("Cannot lazily initialize collection of role");
assertThat(ex.getMessage()).contains("(no session)");
}
@Test
void accessingToOneProxyAfterSessionClose_throwsTheOtherLazyInitializationMessage() {
Long id = seedBookWithReview("To-One Proxy Message");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
ProxyBook proxy = session.getReference(ProxyBook.class, id);
em.getTransaction().commit();
em.close();
LazyInitializationException ex = assertThrows(LazyInitializationException.class, proxy::getTitle);
DEMO.info("to-one proxy verbatim message: {}", ex.getMessage());
// Verbatim in 7.4.5.Final: "Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#4] - no session"
// Capital "Could", lower-case "no session" -- NOT the commonly-quoted
// "could not initialize proxy - no Session" (capital Session). Both this article's
// sources and long-standing folklore get the capitalization backwards.
assertThat(ex.getMessage()).contains("Could not initialize proxy");
assertThat(ex.getMessage()).contains("no session");
}
@Test
void hibernateInitialize_forcesLoadInsideTransaction_thenIsInitializedFlips() {
Long id = seedBookWithReview("Initialize Me");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ProxyBook book = em.find(ProxyBook.class, id);
assertThat(Hibernate.isInitialized(book.getReviews())).isFalse();
Hibernate.initialize(book.getReviews());
assertThat(Hibernate.isInitialized(book.getReviews())).isTrue();
em.getTransaction().commit();
em.close();
// Now that it was initialized while the session was open, reading it after close
// works -- no exception, because there is nothing left to lazily fetch.
assertThat(book.getReviews()).hasSize(1);
assertThat(book.getReviews().get(0).getComment()).isEqualTo("Great book");
}
@Test
void hibernateUnproxy_onManyToOneReference_returnsRealInstance_notTheProxySubclass() {
Long id = seedBookWithReview("Unproxy Me");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
ProxyBook proxy = session.getReference(ProxyBook.class, id);
DEMO.info("getReference() proxy class: {}", proxy.getClass().getName());
assertThat(proxy.getClass()).isNotEqualTo(ProxyBook.class);
assertThat(Hibernate.isInitialized(proxy)).isFalse();
Object unproxied = Hibernate.unproxy(proxy);
DEMO.info("Hibernate.unproxy(proxy) class: {}", unproxied.getClass().getName());
assertThat(unproxied.getClass()).isEqualTo(ProxyBook.class);
assertThat(Hibernate.isInitialized(proxy))
.as("Hibernate.unproxy() initializes the proxy as a side effect")
.isTrue();
// Two-arg overload: Hibernate.unproxy(T, Class<T>)
ProxyBook typed = Hibernate.unproxy(proxy, ProxyBook.class);
assertThat(typed.getClass()).isEqualTo(ProxyBook.class);
em.getTransaction().commit();
em.close();
}
}
@@ -0,0 +1,65 @@
package com.ankurm.hibernatedemo.proxy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.LazyInitializationException;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Backs docs/11-proxies-and-lazy-initialization.md, chapter "hibernate.enable_lazy_load_no_trans: does it still exist?".
*
* <p>{@code org.hibernate.cfg.TransactionSettings.ENABLE_LAZY_LOAD_NO_TRANS} still resolves to
* the constant string {@code hibernate.enable_lazy_load_no_trans} in Hibernate 7.4.5.Final --
* confirmed by {@code javap} on the jar (see docs/output/proxy-settings-javap.txt). It is NOT
* removed. It IS annotated {@code @org.hibernate.cfg.Unsafe}, a marker interface with no members
* that Hibernate's own codebase uses to flag settings the team does not want you reaching for.
* This test proves the setting still functionally does something in 7.4.5: with it enabled, a
* lazy proxy can be touched after its own originating transaction/session has ended, because
* Hibernate opens a temporary session behind the scenes to service exactly that one lazy load.
*/
@SpringBootTest
@TestPropertySource(properties = {
"spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true"
})
class OpenInViewAndLazyLoadNoTransTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void withEnableLazyLoadNoTrans_toOneProxyInitializesAfterSessionCloses_viaTemporarySession() {
EntityManager seedEm = emf.createEntityManager();
seedEm.getTransaction().begin();
ProxyPublisher publisher = new ProxyPublisher("No-Trans Press");
seedEm.persist(publisher);
ProxyBook book = new ProxyBook("No-Trans Book", publisher);
seedEm.persist(book);
seedEm.getTransaction().commit();
Long id = book.getId();
seedEm.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
ProxyBook proxy = session.getReference(ProxyBook.class, id);
em.getTransaction().commit();
em.close();
// No exception here: with enable_lazy_load_no_trans=true, Hibernate 7.4.5 opens a
// temporary session to service this access instead of throwing.
String title = proxy.getTitle();
DEMO.info("enable_lazy_load_no_trans=true: proxy.getTitle() after close returned '{}' with no exception", title);
assertThat(title).isEqualTo("No-Trans Book");
}
}
@@ -0,0 +1,27 @@
package com.ankurm.hibernatedemo.proxy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* Test-only support for docs/11-proxies-and-lazy-initialization.md's OSIV chapter (OsivMaskingTest). Returns the
* managed entity directly (not a DTO) ON PURPOSE -- Jackson's serialization of
* {@code getReviews()} on the response-writing thread is the exact moment open-in-view either
* saves you (session still bound, proxy initializes) or doesn't (session long closed,
* LazyInitializationException).
*/
@RestController
public class OsivBookController {
private final OsivBookService service;
public OsivBookController(OsivBookService service) {
this.service = service;
}
@GetMapping("/osiv/books/{id}")
public ProxyBook getBook(@PathVariable Long id) {
return service.findBook(id);
}
}
@@ -0,0 +1,27 @@
package com.ankurm.hibernatedemo.proxy;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Test-only support for docs/11-proxies-and-lazy-initialization.md's OSIV chapter (OsivMaskingTest). Deliberately
* uses the container-managed, request/transaction-synchronized {@link EntityManager} (via
* {@code @PersistenceContext}), not a manually created one -- open-in-view only has anything
* to bind to the request thread when the shared, Spring-managed EntityManager is the one in
* play.
*/
@Service
public class OsivBookService {
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public ProxyBook findBook(Long id) {
// Deliberately does NOT touch getReviews() here -- whether that succeeds later, during
// JSON serialization in the web layer, is exactly the thing under test.
return entityManager.find(ProxyBook.class, id);
}
}
@@ -0,0 +1,89 @@
package com.ankurm.hibernatedemo.proxy;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.test.web.server.LocalServerPort;
/**
* Backs docs/11-proxies-and-lazy-initialization.md, chapter "OSIV: the default, its warning, and what it actually
* masks". Deliberately does NOT set {@code spring.jpa.open-in-view} -- Boot's own default
* (true) is what is under test here, including the startup warning Boot prints because you
* didn't set it explicitly.
*
* <p>Needs a real servlet request (see {@link OsivBookController}, {@link OsivBookService}):
* open-in-view is implemented by {@code OpenEntityManagerInViewInterceptor}, which only exists
* for actual web applications -- the base app here runs with
* {@code spring.main.web-application-type=none}, so this test overrides that to {@code servlet}
* and spins up a random-port embedded Tomcat just for itself.
*/
@ExtendWith(OutputCaptureExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
// A dedicated config file that has NO spring.jpa.open-in-view key at all -- the shared
// src/main/resources/application.yml sets it to false explicitly, which would hide
// both the warning and the masking behaviour under test here.
"spring.config.name=osiv-default-test"
})
class OsivDefaultWarningTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@LocalServerPort
private int port;
private Long seedBookWithReview() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ProxyPublisher publisher = new ProxyPublisher("OSIV Default Press");
em.persist(publisher);
ProxyBook book = new ProxyBook("OSIV Default Book", publisher);
em.persist(book);
ProxyReview review = new ProxyReview("Rendered fine", book);
em.persist(review);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
@Test
void defaultOpenInView_printsStartupWarning_andMasksLazyLoadingDuringSerialization(CapturedOutput output) throws Exception {
DEMO.info("checked at context-startup time: {}", output.toString().contains("open-in-view")
? "the OSIV warning line is present in the captured log"
: "NOT FOUND");
assertThat(output.toString())
.as("Boot must print its open-in-view default warning verbatim when the property is left unset")
.contains("spring.jpa.open-in-view is enabled by default");
Long id = seedBookWithReview();
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/osiv/books/" + id)).GET().build(),
HttpResponse.BodyHandlers.ofString());
DEMO.info("default OSIV (true): GET /osiv/books/{} -> status {}, body {}", id, response.statusCode(), response.body());
// With OSIV on, the session/EntityManager is still bound to the request thread while
// Jackson serializes the response, so getReviews() initializes successfully instead of
// throwing -- the exception is masked, not fixed.
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("Rendered fine");
}
}
@@ -0,0 +1,95 @@
package com.ankurm.hibernatedemo.proxy;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.test.web.server.LocalServerPort;
/**
* Backs docs/11-proxies-and-lazy-initialization.md, chapter "OSIV: the default, its warning, and what it actually
* masks" -- the "off" half of {@link OsivDefaultWarningTest}. Same controller, same service,
* same entity graph; only {@code spring.jpa.open-in-view} changes: the base
* src/main/resources/application.yml already sets it to {@code false} explicitly, which this
* test relies on and additionally restates for clarity.
*
* <p>The exception surfaces server-side (visible in the application log, asserted here via
* {@link CapturedOutput}) even though the HTTP error body Boot's default
* {@code BasicErrorController} returns is a bare
* {@code {"timestamp":...,"status":500,"error":"Internal Server Error","path":...}} --
* {@code server.error.include-message=always} does not surface it in the JSON body for this
* failure mode, because the failure happens inside the {@code HttpMessageConverter} while
* writing the response, which Boot reports as a generic 500 rather than routing the original
* exception's message into the error attributes map. That gap is itself worth a line in the
* article: don't rely on the HTTP response body to tell you WHY a 500 happened here -- check
* the log.
*/
@ExtendWith(OutputCaptureExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.main.web-application-type=servlet",
"spring.jpa.open-in-view=false"
})
class OsivDisabledExceptionTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@LocalServerPort
private int port;
private Long seedBookWithReview() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ProxyPublisher publisher = new ProxyPublisher("OSIV Disabled Press");
em.persist(publisher);
ProxyBook book = new ProxyBook("OSIV Disabled Book", publisher);
em.persist(book);
ProxyReview review = new ProxyReview("Never rendered", book);
em.persist(review);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
@Test
void openInViewFalse_theSameControllerNowThrowsDuringSerialization(CapturedOutput output) throws Exception {
Long id = seedBookWithReview();
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/osiv/books/" + id)).GET().build(),
HttpResponse.BodyHandlers.ofString());
DEMO.info("open-in-view=false: GET /osiv/books/{} -> status {}, body {}", id, response.statusCode(), response.body());
assertThat(response.statusCode())
.as("with OSIV off, the same request that worked in OsivDefaultWarningTest now fails")
.isEqualTo(500);
// Spring logs this as "Resolved [HttpMessageNotWritableException: Could not write
// JSON: Cannot lazily initialize collection of role '...' (no session)]" -- the
// OUTER exception is HttpMessageNotWritableException (thrown by the Jackson message
// converter while writing the response body); LazyInitializationException's own
// MESSAGE text is nested inside it, but the log line does not print
// LazyInitializationException's class name. Assert on the message text that is
// actually, verifiably there.
assertThat(output.toString())
.as("the real cause is visible server-side even though the HTTP error body is generic")
.contains("HttpMessageNotWritableException")
.contains("Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews'")
.contains("(no session)");
}
}
@@ -0,0 +1,101 @@
package com.ankurm.hibernatedemo.proxy;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.Hibernate;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/11-proxies-and-lazy-initialization.md, chapter "What a proxy actually is".
*
* <p>{@code getReference()} does not return a {@code ProxyBook} instance -- it returns a
* ByteBuddy-generated subclass. {@code instanceof} sees through it; a naive
* {@code getClass() == SomeEntity.class} check, or the default {@code Object.equals()}/
* {@code hashCode()} pair, does not.
*/
@SpringBootTest
class ProxyIdentityTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedPublisher(String name) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ProxyPublisher p = new ProxyPublisher(name);
em.persist(p);
em.getTransaction().commit();
Long id = p.getId();
em.close();
return id;
}
@Test
void getReference_returnsAByteBuddyGeneratedSubclass_notThePlainEntityClass() {
Long id = seedPublisher("Identity Press");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
ProxyPublisher proxy = session.getReference(ProxyPublisher.class, id);
String className = proxy.getClass().getName();
DEMO.info("getReference() runtime class: {}", className);
// Hibernate 7 generates the proxy as a NESTED class of the entity itself, named
// "$HibernateProxy" -- not a package-level class, and not the Javassist-era naming
// scheme (EntityName_$$_javassist_N) that a lot of still-circulating blog posts show.
assertThat(className).isEqualTo(ProxyPublisher.class.getName() + "$HibernateProxy");
assertThat(className).isNotEqualTo(ProxyPublisher.class.getName());
assertThat(proxy).isInstanceOf(ProxyPublisher.class);
assertThat(proxy.getClass()).isNotEqualTo(ProxyPublisher.class);
assertThat(Hibernate.getClass(proxy))
.as("Hibernate.getClass() sees through the proxy to report the real entity class")
.isEqualTo(ProxyPublisher.class);
em.getTransaction().commit();
em.close();
}
@Test
void naiveEqualsAndHashSet_cannotRecognizeProxyAndRealInstanceAsTheSameRow() {
Long id = seedPublisher("Naive Equals Press");
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
ProxyPublisher real = em1.unwrap(Session.class).get(ProxyPublisher.class, id);
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
ProxyPublisher proxy = em2.unwrap(Session.class).getReference(ProxyPublisher.class, id);
// ProxyPublisher does not override equals()/hashCode() -- default Object identity.
assertThat(real.getClass()).isNotEqualTo(proxy.getClass());
assertThat(real.equals(proxy))
.as("default equals() compares object identity; a proxy is never == the real instance")
.isFalse();
Set<ProxyPublisher> set = new HashSet<>();
set.add(real);
assertThat(set.contains(proxy))
.as("same underlying row, but the HashSet cannot tell -- this is the naive-equals() trap")
.isFalse();
em1.getTransaction().commit();
em1.close();
em2.getTransaction().commit();
em2.close();
}
}
@@ -0,0 +1,275 @@
package com.ankurm.hibernatedemo.query;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaDelete;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.CriteriaUpdate;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import java.time.LocalDate;
import java.util.List;
import org.hibernate.stat.Statistics;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4880 (Criteria API). Docs: docs/16-criteria-queries.md.
*
* <p>Shares {@link Employee}/{@link Department} with {@link HqlQueryTest}. The static metamodel
* classes ({@link Employee_}, {@link Department_}) are real, generated by {@code
* hibernate-jpamodelgen} at build time (see the {@code annotationProcessorPaths} entry in
* pom.xml) -- not hand-written stand-ins.
*
* <p>Run with {@code ./mvnw -Dtest=CriteriaQueryTest test}.
*/
@SpringBootTest
class CriteriaQueryTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long engineeringId;
@BeforeEach
void seed() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM Employee").executeUpdate();
em.createQuery("DELETE FROM QueryDept").executeUpdate();
Department engineering = new Department("Engineering");
Department marketing = new Department("Marketing");
em.persist(engineering);
em.persist(marketing);
em.persist(new Employee("Ada", "Byron", 95_000.0, "ACTIVE", LocalDate.of(2019, 3, 1), engineering));
em.persist(new Employee("Grace", "Hopper", 98_000.0, "ACTIVE", LocalDate.of(2018, 6, 15), engineering));
em.persist(new Employee("Linus", "Torvalds", 92_000.0, "INACTIVE", LocalDate.of(2015, 1, 10), engineering));
em.persist(new Employee("Margaret", "Hamilton", 72_000.0, "ACTIVE", LocalDate.of(2020, 9, 1), marketing));
em.persist(new Employee("Katherine", "Johnson", 71_000.0, "ACTIVE", LocalDate.of(2021, 2, 20), marketing));
em.getTransaction().commit();
engineeringId = engineering.getId();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
@Test
void basicSelection_returnsEveryRow() {
EntityManager em = emf.createEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> root = cr.from(Employee.class);
cr.select(root);
List<Employee> results = em.createQuery(cr).getResultList();
assertThat(results).hasSize(5);
em.close();
}
@Test
void stringPathPredicates_andCombined_filterCorrectly() {
EntityManager em = emf.createEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> root = cr.from(Employee.class);
Predicate salaryGreater = cb.gt(root.get("salary"), 90_000.0);
Predicate nameLike = cb.like(root.get("lastName"), "%o%");
cr.select(root).where(cb.and(salaryGreater, nameLike)).orderBy(cb.asc(root.get("lastName")));
List<Employee> filtered = em.createQuery(cr).getResultList();
// salary > 90000 keeps Byron/Hopper/Torvalds; lastName LIKE '%o%' does NOT narrow that
// set further -- "Byron", "Hopper" and "Torvalds" all contain an 'o'.
assertThat(filtered).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds");
DEMO.info("stringPathPredicates: {}", filtered.stream().map(Employee::getLastName).toList());
em.close();
}
@Test
void staticMetamodel_isTypeSafeAndProducesTheSameResultAsStringPath() {
// The same query as above, but built through the generated Employee_ metamodel
// instead of root.get("salary") string paths. Proves the metamodel classes are real
// and functional, not just present on the classpath.
EntityManager em = emf.createEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> root = cr.from(Employee.class);
cr.select(root)
.where(cb.gt(root.get(Employee_.salary), 90_000.0))
.orderBy(cb.asc(root.get(Employee_.lastName)));
List<Employee> results = em.createQuery(cr).getResultList();
assertThat(results).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds");
DEMO.info("staticMetamodel: {}", results.stream().map(Employee::getLastName).toList());
em.close();
}
@Test
void joinViaMetamodel_filtersByRelatedEntity() {
EntityManager em = emf.createEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> root = cr.from(Employee.class);
Join<Employee, Department> department = root.join(Employee_.department, JoinType.INNER);
cr.select(root).where(cb.equal(department.get(Department_.name), "Engineering"));
List<Employee> engineeringStaff = em.createQuery(cr).getResultList();
assertThat(engineeringStaff).hasSize(3);
DEMO.info("joinViaMetamodel: {} engineering employees", engineeringStaff.size());
em.close();
}
@Test
void rootJoin_doesNotForceEagerLoad_rootFetchDoes() {
// root.join() builds a SQL join for filtering but does NOT initialize the association
// for the returned entities -- touching it afterward is a separate SELECT per entity,
// the same N+1 risk as HQL's plain JOIN. root.fetch() actually populates it.
EntityManager em1 = emf.createEntityManager();
CriteriaBuilder cb1 = em1.getCriteriaBuilder();
CriteriaQuery<Employee> cr1 = cb1.createQuery(Employee.class);
Root<Employee> root1 = cr1.from(Employee.class);
root1.join(Employee_.department, JoinType.INNER);
cr1.select(root1);
stats().clear();
List<Employee> viaJoin = em1.createQuery(cr1).getResultList();
long afterSelect = stats().getPrepareStatementCount();
viaJoin.forEach(e -> e.getDepartment().getName());
long afterTouch = stats().getPrepareStatementCount();
assertThat(afterTouch).as("root.join() alone still requires extra SELECTs to read the association")
.isGreaterThan(afterSelect);
em1.close();
EntityManager em2 = emf.createEntityManager();
CriteriaBuilder cb2 = em2.getCriteriaBuilder();
CriteriaQuery<Employee> cr2 = cb2.createQuery(Employee.class);
Root<Employee> root2 = cr2.from(Employee.class);
root2.fetch(Employee_.department, JoinType.INNER);
cr2.select(root2).distinct(true);
stats().clear();
List<Employee> viaFetch = em2.createQuery(cr2).getResultList();
viaFetch.forEach(e -> e.getDepartment().getName());
long totalWithFetch = stats().getPrepareStatementCount();
assertThat(totalWithFetch).as("root.fetch() loads the association in the same SELECT")
.isEqualTo(1L);
DEMO.info("rootJoinVsFetch: join+touch={} statements, fetch+touch={} statement", afterTouch, totalWithFetch);
em2.close();
}
@Test
void aggregation_avgSalary_matchesHandComputedValue() {
EntityManager em = emf.createEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Double> avgQuery = cb.createQuery(Double.class);
Root<Employee> avgRoot = avgQuery.from(Employee.class);
avgQuery.select(cb.avg(avgRoot.get(Employee_.salary)));
Double averageSalary = em.createQuery(avgQuery).getSingleResult();
double expected = (95_000.0 + 98_000.0 + 92_000.0 + 72_000.0 + 71_000.0) / 5;
assertThat(averageSalary).isCloseTo(expected, org.assertj.core.data.Offset.offset(0.01));
DEMO.info("aggregation: average salary = {}", averageSalary);
em.close();
}
@Test
void subquery_findsAboveAverageEarners() {
EntityManager em = emf.createEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> mainQuery = cb.createQuery(Employee.class);
Root<Employee> empRoot = mainQuery.from(Employee.class);
Subquery<Double> sub = mainQuery.subquery(Double.class);
Root<Employee> subRoot = sub.from(Employee.class);
sub.select(cb.avg(subRoot.get(Employee_.salary)));
mainQuery.select(empRoot).where(cb.gt(empRoot.get(Employee_.salary), sub)).orderBy(cb.asc(empRoot.get(Employee_.lastName)));
List<Employee> highEarners = em.createQuery(mainQuery).getResultList();
// Company-wide average is 85600 (428000/5) -- the two Marketing salaries (72000,
// 71000) drag it well below every Engineering salary, so all three Engineering
// employees clear it, not just the single highest earner.
assertThat(highEarners).extracting(Employee::getLastName).containsExactly("Byron", "Hopper", "Torvalds");
DEMO.info("subquery: above-average earners (avg=85600) = {}", highEarners.stream().map(Employee::getLastName).toList());
em.close();
}
@Test
void criteriaUpdate_bulkRaisesSalaryForOneDepartment() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaUpdate<Employee> update = cb.createCriteriaUpdate(Employee.class);
Root<Employee> updRoot = update.from(Employee.class);
update.set(Employee_.salary, cb.prod(updRoot.get(Employee_.salary), 1.1))
.where(cb.equal(updRoot.get(Employee_.department).get(Department_.id), engineeringId));
int updated = em.createQuery(update).executeUpdate();
assertThat(updated).isEqualTo(3);
em.clear();
Double newAdaSalary = em.createQuery("SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class)
.getSingleResult();
assertThat(newAdaSalary).isCloseTo(95_000.0 * 1.1, org.assertj.core.data.Offset.offset(0.01));
em.getTransaction().commit();
DEMO.info("criteriaUpdate: {} rows updated, Ada's new salary = {}", updated, newAdaSalary);
em.close();
}
@Test
void criteriaDelete_removesEmployeesWithNoDepartment() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// Insert one departmentless employee to have something real for CriteriaDelete to remove.
em.persist(new Employee("Orphan", "NoDept", 50_000.0, "ACTIVE", LocalDate.now(), null));
em.flush();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaDelete<Employee> delete = cb.createCriteriaDelete(Employee.class);
Root<Employee> delRoot = delete.from(Employee.class);
delete.where(cb.isNull(delRoot.get(Employee_.department)));
int deleted = em.createQuery(delete).executeUpdate();
assertThat(deleted).isEqualTo(1);
long remaining = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult();
assertThat(remaining).isEqualTo(5L);
em.getTransaction().commit();
DEMO.info("criteriaDelete: deleted={}, remaining={}", deleted, remaining);
em.close();
}
@Test
void orPredicate_combinesConditionsWithVarargsOverload() {
EntityManager em = emf.createEntityManager();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> root = cr.from(Employee.class);
cr.select(root).where(cb.or(
cb.equal(root.get(Employee_.status), "INACTIVE"),
cb.equal(root.get(Employee_.lastName), "Hamilton")));
List<Employee> results = em.createQuery(cr).getResultList();
assertThat(results).extracting(Employee::getLastName).containsExactlyInAnyOrder("Torvalds", "Hamilton");
DEMO.info("orPredicate: {}", results.stream().map(Employee::getLastName).toList());
em.close();
}
}
@@ -0,0 +1,294 @@
package com.ankurm.hibernatedemo.query;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.LocalDate;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.query.SemanticException;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4879 (HQL). Docs: docs/15-hql-queries.md.
*
* <p>Run with {@code ./mvnw -Dtest=HqlQueryTest test}. Every assertion here was first observed by
* running the same query and reading the log, then pinned down as an assertion.
*/
@SpringBootTest
class HqlQueryTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long engineeringId;
private Long marketingId;
@BeforeEach
void seed() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// This method-scoped H2 database (jdbc:h2:mem:hibernate-demo) is shared across every
// test in the class run, not reset between tests, so each test must clear its own
// tables first -- otherwise counts creep upward test by test.
em.createQuery("DELETE FROM Employee").executeUpdate();
em.createQuery("DELETE FROM QueryDept").executeUpdate();
Department engineering = new Department("Engineering");
Department marketing = new Department("Marketing");
em.persist(engineering);
em.persist(marketing);
em.persist(new Employee("Ada", "Byron", 95_000.0, "ACTIVE", LocalDate.of(2019, 3, 1), engineering));
em.persist(new Employee("Grace", "Hopper", 98_000.0, "ACTIVE", LocalDate.of(2018, 6, 15), engineering));
em.persist(new Employee("Linus", "Torvalds", 92_000.0, "INACTIVE", LocalDate.of(2015, 1, 10), engineering));
em.persist(new Employee("Margaret", "Hamilton", 72_000.0, "ACTIVE", LocalDate.of(2020, 9, 1), marketing));
em.persist(new Employee("Katherine", "Johnson", 71_000.0, "ACTIVE", LocalDate.of(2021, 2, 20), marketing));
em.getTransaction().commit();
engineeringId = engineering.getId();
marketingId = marketing.getId();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
@Test
void selectAll_returnsEveryRow() {
EntityManager em = emf.createEntityManager();
List<Employee> all = em.createQuery("FROM Employee", Employee.class).getResultList();
assertThat(all).hasSize(5);
em.close();
}
@Test
void whereWithNamedParameter_filtersCorrectly() {
EntityManager em = emf.createEntityManager();
List<Employee> active = em.createQuery(
"SELECT e FROM Employee e WHERE e.status = :status", Employee.class)
.setParameter("status", "ACTIVE")
.getResultList();
assertThat(active).hasSize(4).allMatch(e -> "ACTIVE".equals(e.getStatus()));
DEMO.info("whereWithNamedParameter: {} active employees", active.size());
em.close();
}
@Test
void columnNameInsteadOfFieldName_failsAtQueryTimeNotSilently() {
// The classic HQL pitfall: writing the database column name (first_name) instead of
// the entity field name (firstName). This is NOT a silent wrong-result bug -- HQL
// resolves against the entity model, so an unknown path fails loudly before any SQL
// is sent.
EntityManager em = emf.createEntityManager();
assertThatThrownBy(() -> em.createQuery("SELECT e FROM Employee e WHERE e.first_name = 'Ada'", Employee.class))
.isInstanceOfAny(IllegalArgumentException.class, SemanticException.class)
.satisfies(ex -> DEMO.info("columnNameInsteadOfFieldName: {}: {}", ex.getClass().getSimpleName(), ex.getMessage()));
em.close();
}
@Test
void joinWithoutFetch_causesOneQueryPerDepartmentAccess() {
EntityManager em = emf.createEntityManager();
stats().clear();
List<Employee> withDept = em.createQuery(
"SELECT e FROM Employee e JOIN e.department d WHERE d.name = :deptName", Employee.class)
.setParameter("deptName", "Engineering")
.getResultList();
long afterQuery = stats().getPrepareStatementCount();
assertThat(afterQuery).as("plain JOIN issues one SELECT for the employees").isEqualTo(1);
// Touching the association now triggers a SEPARATE select per distinct department --
// this is the N+1 that JOIN alone (without FETCH) does not prevent.
withDept.forEach(e -> e.getDepartment().getName());
long afterTouch = stats().getPrepareStatementCount();
assertThat(afterTouch).as("touching the lazy association after a plain JOIN fires extra SELECTs")
.isGreaterThan(afterQuery);
DEMO.info("joinWithoutFetch: {} statements for the query, {} after touching department", afterQuery, afterTouch);
em.close();
}
@Test
void joinFetch_loadsAssociationInOneQuery() {
EntityManager em = emf.createEntityManager();
stats().clear();
List<Employee> withFetch = em.createQuery(
"SELECT e FROM Employee e LEFT JOIN FETCH e.department", Employee.class)
.getResultList();
withFetch.forEach(e -> e.getDepartment().getName());
long total = stats().getPrepareStatementCount();
assertThat(total).as("JOIN FETCH loads employees AND departments in exactly one SELECT, even after touching department")
.isEqualTo(1);
DEMO.info("joinFetch: {} statement total, {} rows", total, withFetch.size());
em.close();
}
@Test
void aggregateCount_matchesSeedSize() {
EntityManager em = emf.createEntityManager();
Long count = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult();
assertThat(count).isEqualTo(5L);
DEMO.info("aggregateCount: {}", count);
em.close();
}
@Test
void avgSalaryGroupByDepartment_matchesHandComputedAverages() {
EntityManager em = emf.createEntityManager();
List<Object[]> rows = em.createQuery(
"SELECT d.name, AVG(e.salary) FROM Employee e JOIN e.department d GROUP BY d.name ORDER BY d.name",
Object[].class)
.getResultList();
assertThat(rows).hasSize(2);
assertThat((String) rows.get(0)[0]).isEqualTo("Engineering");
assertThat((Double) rows.get(0)[1]).isCloseTo((95_000.0 + 98_000.0 + 92_000.0) / 3, org.assertj.core.data.Offset.offset(0.01));
assertThat((String) rows.get(1)[0]).isEqualTo("Marketing");
assertThat((Double) rows.get(1)[1]).isCloseTo((72_000.0 + 71_000.0) / 2, org.assertj.core.data.Offset.offset(0.01));
rows.forEach(r -> DEMO.info("avgSalaryGroupByDepartment: {} -> {}", r[0], r[1]));
em.close();
}
@Test
void pagination_returnsCorrectSlice() {
EntityManager em = emf.createEntityManager();
List<Employee> page1 = em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();
List<Employee> page2 = em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class)
.setFirstResult(2)
.setMaxResults(2)
.getResultList();
assertThat(page1).hasSize(2);
assertThat(page2).hasSize(2);
assertThat(page1).extracting(Employee::getLastName).doesNotContainAnyElementsOf(
page2.stream().map(Employee::getLastName).toList());
DEMO.info("pagination: page1={}, page2={}", page1.stream().map(Employee::getLastName).toList(),
page2.stream().map(Employee::getLastName).toList());
em.close();
}
@Test
void bulkUpdate_changesRowsButNotAlreadyLoadedEntityInSameSession() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// Load one INACTIVE-bound employee into the persistence context first.
Employee loaded = em.createQuery(
"SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class)
.getSingleResult();
assertThat(loaded.getStatus()).isEqualTo("INACTIVE");
int updated = em.createQuery(
"UPDATE Employee e SET e.status = 'ARCHIVED' WHERE e.status = 'INACTIVE'")
.executeUpdate();
assertThat(updated).isEqualTo(1);
// The row changed in the database, but the already-loaded managed entity was never
// touched by the bulk UPDATE -- it still shows the old in-memory value until refreshed.
assertThat(loaded.getStatus()).as("bulk UPDATE bypasses the persistence context for already-loaded entities")
.isEqualTo("INACTIVE");
em.clear();
Employee reloaded = em.find(Employee.class, loaded.getId());
assertThat(reloaded.getStatus()).as("a fresh load after clear() sees the bulk UPDATE").isEqualTo("ARCHIVED");
em.getTransaction().commit();
DEMO.info("bulkUpdate: updated={} rows, stale in-memory status={}, reloaded status={}",
updated, loaded.getStatus(), reloaded.getStatus());
em.close();
}
@Test
void bulkDelete_removesMatchingRowsOnly() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
int deleted = em.createQuery("DELETE FROM Employee e WHERE e.department.id = :deptId")
.setParameter("deptId", marketingId)
.executeUpdate();
assertThat(deleted).isEqualTo(2);
long remaining = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult();
assertThat(remaining).isEqualTo(3L);
em.getTransaction().commit();
DEMO.info("bulkDelete: deleted={} rows, remaining={}", deleted, remaining);
em.close();
}
// ---- Flush-mode tests use an UPDATE to an already-managed entity, not a new insert.
// An earlier version of these tests persisted a brand-new Employee and expected FlushMode
// to control when it became visible -- that was confounded by GenerationType.IDENTITY
// (chapter 03's own finding: IDENTITY forces an immediate INSERT to obtain the generated
// key, on every persist(), regardless of flush mode). A dirty UPDATE on a loaded managed
// entity is unaffected by id-generation strategy and isolates what flush mode actually
// controls.
@Test
void defaultFlushMode_autoFlushesDirtyUpdateBeforeQuery() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Employee ada = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Byron'", Employee.class).getSingleResult();
ada.setSalary(999_999.0); // dirtied, not explicitly flushed
Double salaryFromFreshQuery = em.createQuery(
"SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class)
.getSingleResult();
assertThat(salaryFromFreshQuery).as("default AUTO flush mode flushes the dirty UPDATE before the query runs")
.isEqualTo(999_999.0);
em.getTransaction().rollback();
DEMO.info("defaultFlushMode: salary seen by a fresh query after an unflushed dirty change = {}", salaryFromFreshQuery);
em.close();
}
@Test
void jakartaCommitFlushMode_suppressesAutoFlushForThisQuery() {
// jakarta.persistence.FlushModeType.COMMIT's javadoc only says a provider is
// "permitted, but not required" to flush before a query -- it's implementation
// defined. Hibernate 7.4.5's actual choice, verified here: it does NOT flush, so the
// query sees the pre-update value.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Employee grace = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Hopper'", Employee.class).getSingleResult();
grace.setSalary(111_111.0);
Double salaryFromFreshQuery = em.createQuery(
"SELECT e.salary FROM Employee e WHERE e.lastName = 'Hopper'", Double.class)
.setFlushMode(jakarta.persistence.FlushModeType.COMMIT)
.getSingleResult();
assertThat(salaryFromFreshQuery).as("Hibernate 7.4.5 chooses not to flush under FlushModeType.COMMIT -- the query still sees the old value")
.isEqualTo(98_000.0);
em.getTransaction().rollback();
DEMO.info("jakartaCommitFlushMode: salary seen by query under FlushModeType.COMMIT = {} (pre-update value was 98000.0)", salaryFromFreshQuery);
em.close();
}
@Test
void nativeManualFlushMode_actuallySuppressesAutoFlush() {
// Hibernate's own native org.hibernate.FlushMode has a fourth value the jakarta enum
// doesn't: MANUAL, which means never auto-flush, full stop -- the one that genuinely
// suppresses it, matching the "set FlushMode.MANUAL for performance-sensitive loops"
// advice, as long as what you're deferring is an UPDATE, not an IDENTITY-generated
// INSERT (see the note above this block).
EntityManager em = emf.createEntityManager();
org.hibernate.Session session = em.unwrap(org.hibernate.Session.class);
em.getTransaction().begin();
Employee linus = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class).getSingleResult();
session.setHibernateFlushMode(org.hibernate.FlushMode.MANUAL);
linus.setSalary(123_123.0);
Double beforeFlush = em.createQuery(
"SELECT e.salary FROM Employee e WHERE e.lastName = 'Torvalds'", Double.class)
.getSingleResult();
assertThat(beforeFlush).as("FlushMode.MANUAL genuinely suppresses auto-flush before this query -- the old salary is still what the DB has")
.isEqualTo(92_000.0);
session.flush();
Double afterFlush = em.createQuery(
"SELECT e.salary FROM Employee e WHERE e.lastName = 'Torvalds'", Double.class)
.getSingleResult();
assertThat(afterFlush).as("an explicit flush() makes the change visible").isEqualTo(123_123.0);
em.getTransaction().rollback();
DEMO.info("nativeManualFlushMode: before explicit flush={}, after={}", beforeFlush, afterFlush);
em.close();
}
}
@@ -0,0 +1,198 @@
package com.ankurm.hibernatedemo.search;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Proves chapter 25's Hibernate Search claims: automatic indexing on persist, full-text search
* with fuzzy matching, exact keyword filtering, sortable range fields, searching through an
* {@code @IndexedEmbedded} association, and rebuilding the index with a {@code MassIndexer}.
*
* <p>All Movie titles in this class carry a unique per-test prefix, and every search query
* filters by that prefix -- the Lucene index, like the H2 database, is shared across every test
* method (and every OTHER test class in this repo that boots the same Spring context), so an
* unscoped query would also match movies indexed by earlier test runs still on disk under
* {@code target/lucene-indexes}.
*
* <p>Docs: docs/25-hibernate-search.md.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class HibernateSearchTest {
@Autowired
private EntityManagerFactory emf;
@Test
void fullTextSearch_withFuzzyMatching_findsATypo() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director coppola = new Director("Fts Francis Ford Coppola");
em.persist(coppola);
em.persist(new Movie("Fts The Godfather", "Drama", 1972, coppola));
em.persist(new Movie("Fts The Godfather Part II", "Drama", 1974, coppola));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
// Deliberate typo: "Godfaher" instead of "Godfather" -- fuzzy(1) tolerates a
// one-character edit distance.
List<Movie> hits = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Godfaher").fuzzy(1))
.fetchHits(20);
em.close();
List<String> titles = hits.stream().map(Movie::getTitle).filter(t -> t.startsWith("Fts")).toList();
System.out.println("RESULT[search-fulltext-fuzzy]: searching title for 'Godfaher' "
+ "(a one-character typo of 'Godfather') with .fuzzy(1) matched: " + titles
+ " -- a plain SQL LIKE '%Godfaher%' would have matched nothing.");
assertThat(titles).containsExactlyInAnyOrder("Fts The Godfather", "Fts The Godfather Part II");
}
@Test
void keywordField_exactMatchOnly_noPartialOrCaseInsensitiveMatch() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director lucas = new Director("Kwf George Lucas");
em.persist(lucas);
em.persist(new Movie("Kwf A New Hope", "Science Fiction", 1977, lucas));
em.persist(new Movie("Kwf Annie Hall", "Comedy", 1977, lucas));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
List<Movie> exactMatch = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("genre").matching("Science Fiction"))
.must(f.match().field("title").matching("Kwf")))
.fetchHits(20);
// A KeywordField compares the WHOLE stored value -- "science" alone (lowercase, partial)
// does not match "Science Fiction" the way a FullTextField's tokenized/lower-cased terms
// would.
List<Movie> partialLowercaseAttempt = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("genre").matching("science"))
.must(f.match().field("title").matching("Kwf")))
.fetchHits(20);
em.close();
System.out.println("RESULT[search-keyword-exact-match]: @KeywordField genre matched by "
+ "the exact stored value 'Science Fiction' -> " + exactMatch.size() + " hit(s) | "
+ "the same field searched with the partial, lowercase 'science' -> "
+ partialLowercaseAttempt.size() + " hit(s) -- a KeywordField is compared whole,"
+ " unlike a FullTextField's tokenized and lower-cased terms.");
assertThat(exactMatch).hasSize(1);
assertThat(partialLowercaseAttempt).isEmpty();
}
@Test
void sortableGenericField_ordersByReleaseYear() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director spielberg = new Director("Sgf Steven Spielberg");
em.persist(spielberg);
em.persist(new Movie("Sgf Jaws", "Thriller", 1975, spielberg));
em.persist(new Movie("Sgf E.T.", "Family", 1982, spielberg));
em.persist(new Movie("Sgf Jurassic Park", "Adventure", 1993, spielberg));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
List<Movie> sortedNewestFirst = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Sgf"))
.sort(f -> f.field("releaseYear").desc())
.fetchHits(20);
em.close();
List<Integer> years = sortedNewestFirst.stream().map(Movie::getReleaseYear).toList();
System.out.println("RESULT[search-sortable-generic-field]: sort(f -> f.field("
+ "\"releaseYear\").desc()) over the 3 Sgf-prefixed movies -- years in the order "
+ "returned: " + years + " -- @GenericField(sortable = Sortable.YES) is what "
+ "makes this sort possible; the default is NOT sortable.");
assertThat(years).containsExactly(1993, 1982, 1975);
}
@Test
void indexedEmbedded_searchesThroughTheAssociation() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director nolan = new Director("Iea Christopher Nolan");
Director tarantino = new Director("Iea Quentin Tarantino");
em.persist(nolan);
em.persist(tarantino);
em.persist(new Movie("Iea Inception", "Science Fiction", 2010, nolan));
em.persist(new Movie("Iea Pulp Fiction", "Crime", 1994, tarantino));
em.getTransaction().commit();
// director.name is a @KeywordField -- compared as one whole value, same as the genre
// field in the previous test -- so the query has to match the FULL stored director name,
// not a substring of it. That's why this searches "Iea Christopher Nolan" whole, not
// just "Nolan".
SearchSession searchSession = Search.session(em);
List<Movie> hits = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("title").matching("Iea"))
.must(f.match().field("director.name").matching("Iea Christopher Nolan")))
.fetchHits(20);
em.close();
List<String> titles = hits.stream().map(Movie::getTitle).toList();
System.out.println("RESULT[search-indexed-embedded]: field 'director.name' matched the "
+ "full keyword value 'Iea Christopher Nolan' -- " + titles + " -- Director "
+ "itself carries no @Indexed annotation at all; its @KeywordField only exists "
+ "inside Movie's index because of @IndexedEmbedded on the director association.");
assertThat(titles).containsExactly("Iea Inception");
}
@Test
void massIndexer_rebuildsTheIndexFromTheDatabase() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director scorsese = new Director("Mix Martin Scorsese");
em.persist(scorsese);
em.persist(new Movie("Mix Goodfellas", "Crime", 1990, scorsese));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
long beforePurge = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Mix"))
.fetchTotalHitCount();
// Purge the index (NOT the database) to simulate an index that has gone stale or was
// never built -- the row is still in H2, only the Lucene index forgot about it.
searchSession.workspace().purge();
long afterPurgeBeforeReindex = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Mix"))
.fetchTotalHitCount();
try {
searchSession.massIndexer(Movie.class).startAndWait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
long afterMassIndexer = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Mix"))
.fetchTotalHitCount();
em.close();
System.out.println("RESULT[search-mass-indexer]: hits before purge=" + beforePurge
+ " | hits after workspace().purge() (row still in H2, index emptied)="
+ afterPurgeBeforeReindex + " | hits after massIndexer(Movie.class)."
+ "startAndWait() (index rebuilt straight from the database, no re-persisting)="
+ afterMassIndexer + ".");
assertThat(beforePurge).isEqualTo(1);
assertThat(afterPurgeBeforeReindex).isZero();
assertThat(afterMassIndexer).isEqualTo(1);
}
}
@@ -0,0 +1,220 @@
package com.ankurm.hibernatedemo.sorting;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Nulls;
import jakarta.persistence.criteria.Root;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Proves chapter 22's sorting claims: {@code @OrderBy} uses property names not column names,
* {@code @SortNatural}/{@code @SortComparator} on element collections, the injection risk in
* naive dynamic HQL sorting and the whitelist fix, Criteria API {@code Order} across a join,
* null precedence via {@code jakarta.persistence.criteria.Nulls}, and case-insensitive sorting.
*
* <p>Docs: docs/22-sorting.md.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class SortingTest {
@Autowired
private EntityManagerFactory emf;
@Test
void orderByUsesPropertyName_notColumnName() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Playlist playlist = new Playlist("orderby-test-mix");
playlist.addSong(new Song("Zulu", "Artist A", 4));
playlist.addSong(new Song("Alpha", "Artist B", 5));
playlist.addSong(new Song("Mike", "Artist C", 3));
em.persist(playlist);
em.getTransaction().commit();
em.clear();
Playlist reloaded = em.find(Playlist.class, playlist.getId());
List<String> titlesInOrder = reloaded.getSongs().stream().map(Song::getTitle).toList();
em.close();
System.out.println("RESULT[sorting-orderby-property-name]: @OrderBy(\"title asc\") on the "
+ "songs collection, where the entity property is 'title' but the mapped column "
+ "is 'song_title' -- loaded order: " + titlesInOrder
+ " -- Hibernate resolved the PROPERTY name to the right column itself.");
assertThat(titlesInOrder).containsExactly("Alpha", "Mike", "Zulu");
}
@Test
void sortNaturalAndSortComparator_onElementCollections() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Playlist playlist = new Playlist("sort-annotations-test");
playlist.getTags().add("rock");
playlist.getTags().add("acoustic");
playlist.getTags().add("live");
playlist.getGenres().add("pop");
playlist.getGenres().add("jazz-fusion");
playlist.getGenres().add("folk");
em.persist(playlist);
em.getTransaction().commit();
em.clear();
Playlist reloaded = em.find(Playlist.class, playlist.getId());
List<String> tagsInOrder = List.copyOf(reloaded.getTags());
List<String> genresInOrder = List.copyOf(reloaded.getGenres());
em.close();
System.out.println("RESULT[sorting-natural-and-comparator]: @SortNatural tags="
+ tagsInOrder + " (plain alphabetical) | @SortComparator genres=" + genresInOrder
+ " (shortest name first, alphabetical tiebreaker) -- both are real java.util."
+ "TreeSet instances rebuilt in memory on load, not an ORDER BY on the collection"
+ " table.");
assertThat(tagsInOrder).containsExactly("acoustic", "live", "rock");
assertThat(genresInOrder).containsExactly("pop", "folk", "jazz-fusion");
}
@Test
void dynamicSorting_unwhitelistedFieldRejected_whitelistedFieldWorks() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Playlist playlist = new Playlist("dynamic-sort-test");
playlist.addSong(new Song("Bravo", "Zeta Band", 2));
playlist.addSong(new Song("Charlie", "Alpha Band", 5));
em.persist(playlist);
em.getTransaction().commit();
// The attack: a caller-supplied string that is not a real Song property at all, aimed at
// proving the whitelist rejects it BEFORE it ever reaches the query -- not that Hibernate
// somehow "sanitizes" it for you.
String malicious = "id) --";
Throwable rejected = org.assertj.core.api.Assertions.catchThrowable(
() -> SongSortField.toHqlPropertyOrThrow(malicious));
String safeField = SongSortField.toHqlPropertyOrThrow("artist");
List<String> artistsSortedSafely = em.createQuery(
"select s.artist from Song s where s.playlist = :p order by s." + safeField,
String.class)
.setParameter("p", playlist)
.getResultList();
em.close();
System.out.println("RESULT[sorting-dynamic-injection-guard]: whitelist rejected '"
+ malicious + "' with " + rejected.getClass().getSimpleName() + " (\""
+ rejected.getMessage() + "\") before it ever reached the query engine | "
+ "whitelisted field 'artist' produced order by s.artist -- result: "
+ artistsSortedSafely + " -- the string never touches the HQL unless it's one of"
+ " the three known-safe property names.");
assertThat(rejected).isInstanceOf(IllegalArgumentException.class);
assertThat(artistsSortedSafely).containsExactly("Alpha Band", "Zeta Band");
}
@Test
void criteriaOrder_acrossAJoin() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Playlist a = new Playlist("B-Playlist");
a.addSong(new Song("Song One", "X", 1));
Playlist b = new Playlist("A-Playlist");
b.addSong(new Song("Song Two", "Y", 1));
em.persist(a);
em.persist(b);
em.getTransaction().commit();
em.clear();
// Scoped to this test's own two songs -- the database is shared across every test method
// in this class, so an unrestricted query here would also pick up playlists created by
// the other test methods.
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> cq = cb.createQuery(String.class);
Root<Song> root = cq.from(Song.class);
Join<Song, Playlist> playlistJoin = root.join("playlist");
cq.select(playlistJoin.get("name"))
.where(root.get("title").in("Song One", "Song Two"))
.orderBy(cb.asc(playlistJoin.get("name")));
List<String> playlistNamesInOrder = em.createQuery(cq).getResultList();
em.close();
System.out.println("RESULT[sorting-criteria-order-join]: Criteria root.join(\"playlist\")"
+ " ordered by the JOINED entity's name -- " + playlistNamesInOrder
+ " -- proves Order in the Criteria API is not limited to the root entity's own"
+ " columns.");
assertThat(playlistNamesInOrder).containsExactly("A-Playlist", "B-Playlist");
}
@Test
void nullPrecedence_viaJakartaPersistenceCriteriaNulls() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Playlist playlist = new Playlist("null-precedence-test");
playlist.addSong(new Song("Rated High", "Artist", 5));
playlist.addSong(new Song("Unrated One", "Artist", null));
playlist.addSong(new Song("Rated Low", "Artist", 1));
playlist.addSong(new Song("Unrated Two", "Artist", null));
em.persist(playlist);
em.getTransaction().commit();
em.clear();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> cq = cb.createQuery(String.class);
Root<Song> root = cq.from(Song.class);
cq.select(root.get("title"))
.where(cb.equal(root.get("playlist").get("id"), playlist.getId()))
.orderBy(cb.asc(root.get("rating"), Nulls.LAST), cb.asc(root.get("title")));
List<String> titlesRatingAscNullsLast = em.createQuery(cq).getResultList();
em.close();
System.out.println("RESULT[sorting-null-precedence]: cb.asc(root.get(\"rating\"), "
+ "Nulls.LAST) -- " + titlesRatingAscNullsLast
+ " -- both unrated songs sort after every rated song regardless of what H2's own"
+ " default null-ordering for ASC would otherwise do, because Nulls.LAST is "
+ "explicit in the generated SQL's ORDER BY, not left to the dialect's default.");
assertThat(titlesRatingAscNullsLast).containsExactly(
"Rated Low", "Rated High", "Unrated One", "Unrated Two");
}
@Test
void caseInsensitiveSorting_viaCbLower() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Playlist playlist = new Playlist("case-insensitive-test");
playlist.addSong(new Song("banana", "Artist", 1));
playlist.addSong(new Song("Apple", "Artist", 1));
playlist.addSong(new Song("cherry", "Artist", 1));
em.persist(playlist);
em.getTransaction().commit();
em.clear();
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> cq = cb.createQuery(String.class);
Root<Song> root = cq.from(Song.class);
cq.select(root.get("title"))
.where(cb.equal(root.get("playlist").get("id"), playlist.getId()))
.orderBy(cb.asc(cb.lower(root.get("title"))));
List<String> caseInsensitiveOrder = em.createQuery(cq).getResultList();
em.close();
System.out.println("RESULT[sorting-case-insensitive]: cb.lower(root.get(\"title\")) "
+ "ascending -- " + caseInsensitiveOrder
+ " -- 'Apple' sorts before 'banana' despite the capital A, because the "
+ "comparison happens on the lower-cased value, not the raw column.");
assertThat(caseInsensitiveOrder).containsExactly("Apple", "banana", "cherry");
}
}
@@ -0,0 +1,115 @@
package com.ankurm.hibernatedemo.testdb;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/09-testing-in-memory-databases.md, chapter "A test that passes on one database and fails on
* another". Deliberately raw JDBC, no Hibernate mapping in the way, to isolate exactly what
* varies between engines rather than what an ORM layer smooths over. Two concrete cases:
*
* <ol>
* <li>An UNQUOTED column named after a SQL reserved word ({@code value}) -- fails to even
* CREATE on some engines, succeeds on others, depending on how strictly each parser
* enforces the reserved-word list. (The word {@code order} was tried first and turned
* out to be the wrong probe -- all three engines reject it identically; see
* docs/output/testdb-reserved-word-survey.txt for the survey that found {@code value}.)</li>
* <li>CHAR(10) padding on read -- checks whether {@code java.sql.ResultSet.getString()}
* returns the space-padded or trimmed value on each engine. The folklore says this is
* "the classic Derby gotcha"; the actual, verified result here is that H2, HSQLDB AND
* Derby all pad identically -- standard CHAR semantics, not a Derby-only quirk. Still
* worth keeping in the article, corrected: the trap is real, just not exclusive to one
* engine.</li>
* </ol>
*/
class CrossDatabaseBehaviorTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private Connection connect(TestDbSupport.Db db, String suffix) throws Exception {
Class.forName(db.driver);
return DriverManager.getConnection(db.url(suffix), db.user, db.password);
}
@Test
void unquotedReservedWordColumn_createTableSucceedsOrFailsDependingOnEngine() throws Exception {
record Outcome(TestDbSupport.Db db, boolean succeeded, String detail) {}
java.util.List<Outcome> outcomes = new java.util.ArrayList<>();
// "order" turned out to be the WRONG word to probe with -- a quick survey of 22
// candidate words (docs/output/testdb-reserved-word-survey.txt) found H2, HSQLDB and
// Derby all reject an unquoted "order" column identically, which proves nothing about
// divergence. "value" is genuinely divergent: H2 reserves it, HSQLDB and Derby do not.
for (TestDbSupport.Db db : new TestDbSupport.Db[] {TestDbSupport.Db.H2, TestDbSupport.Db.HSQLDB, TestDbSupport.Db.DERBY}) {
try (Connection c = connect(db, "reservedword");
Statement st = c.createStatement()) {
st.execute("create table reserved_word_test (id integer, value integer)");
outcomes.add(new Outcome(db, true, "CREATE TABLE succeeded with an unquoted 'value' column"));
} catch (SQLException e) {
outcomes.add(new Outcome(db, false, e.getClass().getSimpleName() + ": " + e.getMessage()));
}
}
for (Outcome o : outcomes) {
DEMO.info("unquoted 'value' column, db={} -> succeeded={}, detail={}", o.db(), o.succeeded(), o.detail());
}
// The actual, verified split: H2 REJECTS an unquoted "value" column, HSQLDB and Derby
// ACCEPT it. This is the concrete "passes on X, fails on Y" case -- an entity mapped
// with a field named exactly `value` and no @Column(name = "\"value\"") escaping will
// build a working schema on two of these three engines and throw a SQL syntax error,
// specifically on H2.
assertThat(outcomes).anySatisfy(o -> {
assertThat(o.db()).isEqualTo(TestDbSupport.Db.H2);
assertThat(o.succeeded()).isFalse();
});
assertThat(outcomes).filteredOn(o -> o.db() != TestDbSupport.Db.H2)
.allSatisfy(o -> assertThat(o.succeeded()).isTrue());
}
@Test
void charPadding_getStringReturnsDifferentlyPaddedValuesAcrossEngines() throws Exception {
record Outcome(TestDbSupport.Db db, String rawValue, int length) {}
java.util.List<Outcome> outcomes = new java.util.ArrayList<>();
for (TestDbSupport.Db db : new TestDbSupport.Db[] {TestDbSupport.Db.H2, TestDbSupport.Db.HSQLDB, TestDbSupport.Db.DERBY}) {
try (Connection c = connect(db, "charpad")) {
try (Statement st = c.createStatement()) {
st.execute("create table char_pad_test (code char(10))");
st.execute("insert into char_pad_test (code) values ('AB')");
}
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("select code from char_pad_test")) {
rs.next();
String value = rs.getString(1);
outcomes.add(new Outcome(db, value, value.length()));
}
}
}
for (Outcome o : outcomes) {
DEMO.info("CHAR(10) holding 'AB', db={} -> getString() = [{}], length={}", o.db(), o.rawValue(), o.length());
}
// CORRECTION to the folklore: this is NOT Derby-specific. All three engines pad
// CHAR(10) to the full length on read -- getString() returns "AB " (length 10)
// on H2 and HSQLDB too, not just Derby. A naive `"AB".equals(value)` fails against ALL
// THREE of these engines for a CHAR column; only VARCHAR does not have this problem.
assertThat(outcomes).allSatisfy(o -> {
assertThat(o.length()).as("db=" + o.db()).isEqualTo(10);
assertThat(o.rawValue()).as("db=" + o.db()).isNotEqualTo("AB");
});
Outcome derby = outcomes.stream().filter(o -> o.db() == TestDbSupport.Db.DERBY).findFirst().orElseThrow();
assertThat("AB".equals(derby.rawValue()))
.as("this is the exact naive comparison that silently fails against a padded CHAR column -- on any of the three")
.isFalse();
}
}
@@ -0,0 +1,61 @@
package com.ankurm.hibernatedemo.testdb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/09-testing-in-memory-databases.md, chapter "What DB_CLOSE_DELAY=-1 actually does". Raw JDBC, no
* Hibernate, to isolate exactly the H2-specific behaviour rather than anything the ORM layer
* might paper over.
*/
class DbCloseDelayTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Test
void withoutCloseDelay_dataDisappearsWhenTheLastConnectionCloses() throws Exception {
String url = "jdbc:h2:mem:no-close-delay-demo"; // no DB_CLOSE_DELAY at all
try (Connection c1 = DriverManager.getConnection(url, "sa", "")) {
try (Statement st = c1.createStatement()) {
st.execute("create table t (id integer)");
st.execute("insert into t values (1)");
}
} // c1 closes here -- this is H2's LAST open connection to this in-memory DB
// A brand new connection to the SAME url now gets a BRAND NEW, EMPTY database --
// querying the table we just created throws, because it no longer exists.
try (Connection c2 = DriverManager.getConnection(url, "sa", "");
Statement st = c2.createStatement()) {
SQLException ex = assertThrows(SQLException.class, () -> st.execute("select * from t"));
DEMO.info("without DB_CLOSE_DELAY=-1, reconnecting after the last close throws: {}: {}",
ex.getClass().getSimpleName(), ex.getMessage());
}
}
@Test
void withCloseDelayMinusOne_dataSurvivesAcrossConnectionCloses() throws Exception {
String url = "jdbc:h2:mem:with-close-delay-demo;DB_CLOSE_DELAY=-1";
try (Connection c1 = DriverManager.getConnection(url, "sa", "")) {
try (Statement st = c1.createStatement()) {
st.execute("create table t (id integer)");
st.execute("insert into t values (1)");
}
} // c1 closes -- but DB_CLOSE_DELAY=-1 tells H2 to keep the in-memory DB alive anyway
try (Connection c2 = DriverManager.getConnection(url, "sa", "");
Statement st = c2.createStatement()) {
var rs = st.executeQuery("select id from t");
assertThat(rs.next()).isTrue();
assertThat(rs.getInt(1)).isEqualTo(1);
DEMO.info("with DB_CLOSE_DELAY=-1, reconnecting after the last close still sees the row inserted earlier");
}
}
}
@@ -0,0 +1,68 @@
package com.ankurm.hibernatedemo.testdb;
import static org.assertj.core.api.Assertions.assertThat;
import org.hibernate.SessionFactory;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/09-testing-in-memory-databases.md, chapter "Which dialect gets auto-selected, and does H2's MODE=
* actually change it". Same {@link TestDbWidget} mapping, five registries (plain H2, H2 in
* PostgreSQL compat mode, H2 in Oracle compat mode, HSQLDB, Derby), one dialect log line each.
*/
class DialectAndDdlTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private String resolvedDialect(TestDbSupport.Db db, String suffix) {
SessionFactory sf = TestDbSupport.buildSessionFactory(db, suffix, "create-drop", TestDbWidget.class);
try {
String dialectClass = sf.unwrap(SessionFactoryImplementor.class)
.getJdbcServices().getDialect().getClass().getName();
DEMO.info("db={} -> resolved dialect = {}", db, dialectClass);
return dialectClass;
} finally {
sf.close();
}
}
@Test
void plainH2_resolvesH2Dialect() {
assertThat(resolvedDialect(TestDbSupport.Db.H2, "plain")).isEqualTo("org.hibernate.dialect.H2Dialect");
}
@Test
void h2WithPostgresModeInTheUrl_stillResolvesH2Dialect_notPostgresDialect() {
// This is the widely-misunderstood point: MODE=PostgreSQL changes what SQL *H2 itself*
// accepts, not which Hibernate Dialect class gets selected. Hibernate has no idea the
// URL contains MODE=PostgreSQL -- dialect resolution reads the JDBC driver's own
// DatabaseMetaData (product name "H2"), and H2 reports itself as H2 regardless of MODE.
assertThat(resolvedDialect(TestDbSupport.Db.H2_POSTGRES_MODE, "pgmode"))
.isEqualTo("org.hibernate.dialect.H2Dialect");
}
@Test
void h2WithOracleModeInTheUrl_stillResolvesH2Dialect_notOracleDialect() {
assertThat(resolvedDialect(TestDbSupport.Db.H2_ORACLE_MODE, "oraclemode"))
.isEqualTo("org.hibernate.dialect.H2Dialect");
}
@Test
void hsqldb_resolvesHSQLDialect() {
assertThat(resolvedDialect(TestDbSupport.Db.HSQLDB, "plain")).isEqualTo("org.hibernate.dialect.HSQLDialect");
}
@Test
void derby_resolvesDerbyDialect_fromTheCommunityDialectsModule_notHibernateCore() {
// NOT org.hibernate.dialect.DerbyDialect -- that class was removed from hibernate-core
// in Hibernate 7. See docs/output/testdb-derby-dialect-not-found.txt for the two
// failures (auto-detection, then the old FQCN) this test's setup had to work around.
assertThat(resolvedDialect(TestDbSupport.Db.DERBY, "plain"))
.isEqualTo("org.hibernate.community.dialect.DerbyDialect");
}
}
@@ -0,0 +1,56 @@
package com.ankurm.hibernatedemo.testdb;
import static org.assertj.core.api.Assertions.assertThat;
import org.hibernate.SessionFactory;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.junit.jupiter.api.Test;
/**
* Merely having hibernate-jcache on the classpath is enough to turn the second-level cache ON,
* with nothing configured. Hibernate 7.4.5 resolves a RegionFactory through the service loader
* and enables L2 caching on the strength of finding one.
*
* <p>That is why {@code application.yml} in this repository pins
* {@code hibernate.cache.use_second_level_cache: false} explicitly. Before it did, a standalone
* test closing the shared Ehcache CacheManager broke an unrelated Spring test's commit with
* {@code Cache[...] is closed} -- see docs/output/testdb-jcache-classpath-pollution.txt.
*
* <p>This test boots a bare SessionFactory with no cache settings at all, so it still observes
* the raw Hibernate default rather than this project's pinned override.
*
* <p>Docs: docs/09-testing-in-memory-databases.md
*/
class JCacheOnClasspathAutoEnablesL2Test {
@Test
void jcacheOnTheClasspath_enablesSecondLevelCaching_withNothingConfigured() {
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.connection.url", "jdbc:h2:mem:jcacheprobe")
.applySetting("hibernate.connection.username", "sa")
.applySetting("hibernate.hbm2ddl.auto", "create-drop")
.build();
try (SessionFactory sf = new MetadataSources(registry)
.addAnnotatedClass(TestDbWidget.class)
.buildMetadata()
.buildSessionFactory()) {
SessionFactoryImplementor impl = sf.unwrap(SessionFactoryImplementor.class);
boolean l2Enabled = impl.getSessionFactoryOptions().isSecondLevelCacheEnabled();
String regionFactory = impl.getCache().getRegionFactory().getClass().getName();
System.out.println("nothing configured about caching anywhere in this bootstrap");
System.out.println("second-level cache enabled = " + l2Enabled);
System.out.println("region factory = " + regionFactory);
assertThat(l2Enabled).isTrue();
assertThat(regionFactory).contains("JCacheRegionFactory");
} finally {
StandardServiceRegistryBuilder.destroy(registry);
}
}
}
@@ -0,0 +1,94 @@
package com.ankurm.hibernatedemo.testdb;
import static org.assertj.core.api.Assertions.assertThat;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/09-testing-in-memory-databases.md, chapter "Schema isolation: create-drop vs rollback vs
* DB_CLOSE_DELAY". Uses one shared {@link EntityManagerFactory} across two ordered tests to
* PROVE the pollution, then a second, correctly-isolated pair to prove the fix -- both against
* plain H2 (the pollution mechanism is not H2-specific, but H2 is what the rest of this repo
* already uses for its default datasource).
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class SchemaIsolationTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static SessionFactory pollutedSf;
@BeforeAll
static void openSharedFactory() {
// DB_CLOSE_DELAY=-1 is what keeps this in-memory H2 database alive across the many
// short-lived connections each Session opens and closes -- without it, H2 tears the
// whole in-memory database down the moment the LAST open connection closes, and the
// next openSession() call would silently reconnect to a brand-new, empty database
// instead of failing loudly.
pollutedSf = TestDbSupport.buildSessionFactory(
TestDbSupport.Db.H2, "isolation-demo", "create-drop", TestDbWidget.class);
}
@AfterAll
static void closeSharedFactory() {
pollutedSf.close();
}
@Test
@Order(1)
void testA_insertsARowWithNoCleanup() {
Session session = pollutedSf.openSession();
session.beginTransaction();
session.persist(new TestDbWidget("AAAAA", 1, true, "left behind by testA"));
session.getTransaction().commit();
session.close();
DEMO.info("testA committed a row and did nothing to clean it up");
}
@Test
@Order(2)
void testB_seesTestAsRow_becauseThereWasNoIsolationBetweenThem() {
Session session = pollutedSf.openSession();
long count = session.createQuery("select count(w) from TestDbWidget w", Long.class).getSingleResult();
session.close();
DEMO.info("testB sees {} row(s) -- at least one is testA's leftover, proving pollution when isolation is skipped", count);
// The point: testB did not insert anything itself yet, but the row count is already
// >= 1, because testA's commit was never rolled back or dropped.
assertThat(count).isGreaterThanOrEqualTo(1);
}
// ---- Now the same shape, but done correctly with @Transactional-style manual rollback ----
@Test
@Order(3)
void testC_insertsARow_thenRollsBackInsteadOfCommitting() {
Session session = pollutedSf.openSession();
session.beginTransaction();
session.persist(new TestDbWidget("BBBBB", 2, true, "rolled back by testC, must not survive"));
long duringTxn = session.createQuery("select count(w) from TestDbWidget w", Long.class).getSingleResult();
session.getTransaction().rollback();
session.close();
DEMO.info("testC saw {} row(s) DURING its own transaction (includes its own uncommitted insert), then rolled back", duringTxn);
}
@Test
@Order(4)
void testD_doesNotSeeTestCsRolledBackRow() {
Session session = pollutedSf.openSession();
long count = session.createQuery(
"select count(w) from TestDbWidget w where w.sku = :sku", Long.class)
.setParameter("sku", "BBBBB")
.getSingleResult();
session.close();
DEMO.info("testD looks specifically for testC's SKU 'BBBBB' -- found {} (must be 0: rollback isolated it)", count);
assertThat(count).isZero();
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hibernatedemo.testdb;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs docs/09-testing-in-memory-databases.md, chapter "Startup time, with the caveat this is a shared
* container". NOT a benchmark -- three runs per database, plain {@code System.nanoTime()},
* one shared, likely-noisy sandbox container. The numbers are indicative of relative order of
* magnitude only, and the docs/output file this writes says so explicitly.
*/
class StartupTimingTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private long timeOneBuild(TestDbSupport.Db db, String suffix) {
long start = System.nanoTime();
SessionFactory sf = TestDbSupport.buildSessionFactory(db, suffix, "create-drop", TestDbWidget.class);
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
sf.close();
return elapsedMs;
}
@Test
void measureStartupAcrossThreeRunsPerDatabase() {
TestDbSupport.Db[] dbs = {TestDbSupport.Db.H2, TestDbSupport.Db.HSQLDB, TestDbSupport.Db.DERBY};
for (TestDbSupport.Db db : dbs) {
long[] times = new long[3];
for (int i = 0; i < 3; i++) {
times[i] = timeOneBuild(db, "timing" + i);
}
DEMO.info("startup ms for {} over 3 runs: {}, {}, {} (sandbox container -- indicative only, not a benchmark)",
db, times[0], times[1], times[2]);
}
}
}
@@ -0,0 +1,75 @@
package com.ankurm.hibernatedemo.testdb;
import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
/**
* Shared plumbing for docs/09-testing-in-memory-databases.md. Builds a plain Hibernate {@link SessionFactory} --
* no Spring involved -- against one of the three databases, with the SAME {@link TestDbWidget}
* mapping every time. Kept deliberately outside Spring Boot's own datasource auto-configuration
* so each database's dialect resolution, generated DDL, and generator behaviour can be observed
* directly and independently, one at a time.
*/
final class TestDbSupport {
enum Db {
H2("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1", "org.h2.Driver", "sa", ""),
H2_POSTGRES_MODE("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", "org.h2.Driver", "sa", ""),
H2_ORACLE_MODE("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1;MODE=Oracle", "org.h2.Driver", "sa", ""),
HSQLDB("jdbc:hsqldb:mem:testdb-%s", "org.hsqldb.jdbc.JDBCDriver", "SA", ""),
DERBY("jdbc:derby:memory:testdb-%s;create=true", "org.apache.derby.jdbc.EmbeddedDriver", "APP", "APP");
final String urlTemplate;
final String driver;
final String user;
final String password;
Db(String urlTemplate, String driver, String user, String password) {
this.urlTemplate = urlTemplate;
this.driver = driver;
this.user = user;
this.password = password;
}
String url(String schemaSuffix) {
return urlTemplate.formatted(schemaSuffix);
}
}
private TestDbSupport() {
}
static StandardServiceRegistry buildRegistry(Db db, String schemaSuffix, String hbm2ddl) {
StandardServiceRegistryBuilder builder = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.driver_class", db.driver)
.applySetting("hibernate.connection.url", db.url(schemaSuffix))
.applySetting("hibernate.connection.username", db.user)
.applySetting("hibernate.connection.password", db.password)
.applySetting("hibernate.hbm2ddl.auto", hbm2ddl)
.applySetting("hibernate.show_sql", "true")
.applySetting("hibernate.format_sql", "false");
if (db == Db.DERBY) {
// Hibernate 7 removed Derby dialect support from hibernate-core. Auto-detection
// against a live Derby connection fails outright ("Unable to determine Dialect for
// Apache Derby 10.16"), and the OLD FQCN org.hibernate.dialect.DerbyDialect no
// longer exists either -- it moved to the separate hibernate-community-dialects
// artifact under a NEW package, org.hibernate.community.dialect.DerbyDialect.
// See docs/output/testdb-derby-dialect-not-found.txt for both failures, verbatim.
builder.applySetting("hibernate.dialect", "org.hibernate.community.dialect.DerbyDialect");
}
return builder.build();
}
static SessionFactory buildSessionFactory(Db db, String schemaSuffix, String hbm2ddl, Class<?>... entities) {
StandardServiceRegistry registry = buildRegistry(db, schemaSuffix, hbm2ddl);
MetadataSources sources = new MetadataSources(registry);
for (Class<?> e : entities) {
sources.addAnnotatedClass(e);
}
Metadata metadata = sources.buildMetadata();
return metadata.buildSessionFactory();
}
}
@@ -0,0 +1,72 @@
package com.ankurm.hibernatedemo.validation;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import java.util.Set;
import org.jboss.weld.environment.se.Weld;
import org.jboss.weld.environment.se.WeldContainer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
/**
* The other half of the measurement {@link PlainValidationNoCdiTest} started: run the identical
* {@link StockLevel}/{@link PositiveInventoryValidator} pair inside a real, standalone CDI
* container (Weld SE -- no Jakarta EE server) with {@code hibernate-validator-cdi} on the
* classpath, and see whether {@code @Inject} actually works this time.
*
* <p>It does, and the mechanism is worth naming rather than treating as magic: {@code
* hibernate-validator-cdi-9.1.3.Final.jar} registers {@code
* org.hibernate.validator.cdi.ValidationExtension} as a {@code jakarta.enterprise.inject.spi.Extension}
* (confirmed via its {@code META-INF/services/jakarta.enterprise.inject.spi.Extension} file).
* Once Weld picks that extension up, it contributes CDI beans for {@code Validator}/{@code
* ValidatorFactory} whose {@code ConstraintValidatorFactory} is {@code
* org.hibernate.validator.cdi.spi.InjectingConstraintValidatorFactory} -- a factory that builds
* each {@code ConstraintValidator} instance through the CDI {@code BeanManager} instead of plain
* reflection, so {@code @Inject} fields on it are resolved like any other managed bean's.
*
* <p>Docs: docs/20-hibernate-validator-cdi.md
*/
class CdiValidationTest {
private WeldContainer container;
@AfterEach
void tearDown() {
if (container != null) {
container.close();
}
}
@Test
void constraintValidatorsInjectField_isProperlyPopulated_insideARunningCdiContainer() {
container = new Weld().initialize();
Validator validator = container.select(Validator.class).get();
Set<ConstraintViolation<StockLevel>> belowThreshold = validator.validate(new StockLevel(3));
Set<ConstraintViolation<StockLevel>> atThreshold = validator.validate(new StockLevel(5));
Set<ConstraintViolation<StockLevel>> aboveThreshold = validator.validate(new StockLevel(10));
System.out.println("RESULT[cdi-validation-injection-works]: validator obtained from a running "
+ "Weld SE container | StockLevel(3) violations=" + belowThreshold.size()
+ " | StockLevel(5) violations=" + atThreshold.size()
+ " | StockLevel(10) violations=" + aboveThreshold.size()
+ " -- InventoryPolicy.minimumThreshold()=5 was actually injected and actually used, "
+ "no NullPointerException anywhere.");
assertThat(belowThreshold)
.as("3 is below the injected policy's threshold of 5 -- a real constraint violation, "
+ "not an exception")
.hasSize(1);
assertThat(atThreshold)
.as("5 meets the threshold exactly")
.isEmpty();
assertThat(aboveThreshold)
.as("10 comfortably clears the threshold")
.isEmpty();
assertThat(belowThreshold.iterator().next().getMessage())
.isEqualTo("quantity is below the minimum inventory threshold");
}
}
@@ -0,0 +1,77 @@
package com.ankurm.hibernatedemo.validation;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.Test;
/**
* The baseline this chapter needs before the CDI test means anything: plain Jakarta Bean
* Validation, bootstrapped the ordinary way with no CDI container running at all. {@code
* Validation.buildDefaultValidatorFactory()} builds its {@code ConstraintValidatorFactory} by
* plain reflection ({@code Class.newInstance()}-equivalent) -- it does not scan for {@code
* @Inject} at all, so {@link PositiveInventoryValidator#policy} is left {@code null}.
*
* <p>Docs: docs/20-hibernate-validator-cdi.md
*/
class PlainValidationNoCdiTest {
@Test
void constraintValidatorsInjectField_isNeverPopulated_withoutACdiContainer() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Throwable thrown = catchThrowable(() -> validator.validate(new StockLevel(3)));
System.out.println("RESULT[cdi-plain-validation-no-injection]: validating StockLevel(3) with "
+ "Validation.buildDefaultValidatorFactory() (no CDI container running) throws "
+ describeChain(thrown));
assertThat(thrown).isNotNull();
Throwable rootCause = rootCause(thrown);
assertThat(rootCause)
.as("the @Inject-annotated policy field was never populated, so calling "
+ "policy.minimumThreshold() inside isValid() throws a plain NullPointerException "
+ "-- not a validation failure, a validator bug caused by the missing CDI container")
.isInstanceOf(NullPointerException.class);
factory.close();
}
private static Throwable catchThrowable(ThrowingRunnable runnable) {
try {
runnable.run();
return null;
} catch (Throwable t) {
return t;
}
}
private static Throwable rootCause(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return cause;
}
private static String describeChain(Throwable t) {
StringBuilder sb = new StringBuilder();
Throwable current = t;
while (current != null) {
sb.append(current.getClass().getName());
if (current.getCause() != null && current.getCause() != current) {
sb.append(" -> caused by ");
}
current = current.getCause() == current ? null : current.getCause();
}
return sb.toString();
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
}
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Backs ankurm.com post 4855 (bootstrapping EntityManager). Docs: docs/17-entitymanager-bootstrap.md.
Deliberately in src/test/resources, not src/main/resources: this repo's Spring Boot app
already bootstraps its own EntityManagerFactory via autoconfiguration, and having a second
persistence.xml on the main classpath risks confusing that path. This one exists purely so
EntityManagerBootstrapTest can call Persistence.createEntityManagerFactory("XmlBootstrapPU")
directly, completely bypassing Spring, which is the point of the chapter.
-->
<persistence version="3.2"
xmlns="https://jakarta.ee/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd">
<persistence-unit name="XmlBootstrapPU" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
<class>com.ankurm.hibernatedemo.bootstrap.BootstrapUser</class>
<properties>
<property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver"/>
<property name="jakarta.persistence.jdbc.url" value="jdbc:h2:mem:bootstrap-xml;DB_CLOSE_DELAY=-1"/>
<property name="jakarta.persistence.jdbc.user" value="sa"/>
<property name="jakarta.persistence.jdbc.password" value=""/>
<property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>
<property name="hibernate.hbm2ddl.auto" value="create-drop"/>
<property name="hibernate.show_sql" value="true"/>
</properties>
</persistence-unit>
</persistence>
@@ -0,0 +1,13 @@
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
"-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">
<hibernate-mapping package="com.ankurm.hibernatedemo.mappingstyle">
<class name="HbmEmployee" table="hbm_employees">
<id name="id" column="id">
<generator class="native"/>
</id>
<property name="firstName" column="first_name" not-null="true" length="100"/>
<property name="email" column="email_address" unique="true"/>
</class>
</hibernate-mapping>
@@ -0,0 +1,15 @@
<config xmlns="http://www.ehcache.org/v3">
<!--
Deliberately missing the default-update-timestamps-region a query-cache-enabled
SessionFactory requires, to capture the real startup failure this produces;
see MissingUpdateTimestampsRegionTest.
-->
<cache alias="productCache">
<expiry>
<ttl unit="minutes">30</ttl>
</expiry>
<resources>
<heap unit="entries">1000</heap>
</resources>
</cache>
</config>
+37
View File
@@ -0,0 +1,37 @@
<config xmlns="http://www.ehcache.org/v3">
<!--
Chapter 18's own ehcache.xml, loaded only by the tests in
com.ankurm.hibernatedemo.cache, each booting an isolated SessionFactory
(see NaturalIdL2CacheTest's pattern in chapter 06) so this never touches
the shared Spring context's pinned use_second_level_cache: false.
-->
<cache alias="productCache">
<expiry>
<ttl unit="minutes">30</ttl>
</expiry>
<resources>
<heap unit="entries">1000</heap>
<offheap unit="MB">10</offheap>
</resources>
</cache>
<!-- Required for hibernate.cache.use_query_cache=true. Omitting this region produces the
real startup failure captured in docs/output/18-missing-timestamps-region.txt. -->
<cache alias="default-update-timestamps-region">
<expiry>
<none/>
</expiry>
<resources>
<heap unit="entries">5000</heap>
</resources>
</cache>
<cache alias="default-query-results-region">
<expiry>
<ttl unit="minutes">10</ttl>
</expiry>
<resources>
<heap unit="entries">200</heap>
</resources>
</cache>
</config>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://www.hibernate.org/xsd/orm/mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
version="7.0">
<entity class="com.ankurm.hibernatedemo.mappingstyle.MappingXmlNaturalIdEntity" access="FIELD">
<table name="mapping_xml_naturalid_widgets"/>
<attributes>
<id name="id">
<generated-value strategy="IDENTITY"/>
</id>
<natural-id mutable="false">
<basic name="sku">
<column name="sku" unique="true" nullable="false"/>
</basic>
</natural-id>
<basic name="name"/>
</attributes>
</entity>
</entity-mappings>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_0.xsd"
version="3.0">
<persistence-unit-metadata>
<xml-mapping-metadata-complete/>
</persistence-unit-metadata>
<entity class="com.ankurm.hibernatedemo.mappingstyle.OverrideEntity" access="FIELD">
<table name="override_entity_2"/>
<attributes>
<basic name="value">
<column name="xml_name_2"/>
</basic>
</attributes>
</entity>
</entity-mappings>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_0.xsd"
version="3.0">
<entity class="com.ankurm.hibernatedemo.mappingstyle.OrmXmlOnlyEntity" access="FIELD">
<table name="xml_only_widgets"/>
<attributes>
<id name="id">
<generated-value strategy="IDENTITY"/>
</id>
<basic name="label">
<column name="label_text" length="80"/>
</basic>
</attributes>
</entity>
</entity-mappings>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_0.xsd"
version="3.0">
<entity class="com.ankurm.hibernatedemo.mappingstyle.OverrideEntity" access="FIELD">
<table name="override_entity"/>
<attributes>
<basic name="value">
<column name="xml_name"/>
</basic>
</attributes>
</entity>
</entity-mappings>
+23
View File
@@ -0,0 +1,23 @@
# Used ONLY by OsivDefaultWarningTest, via spring.config.name=osiv-default-test.
# Deliberately omits spring.jpa.open-in-view entirely -- the point of that test is to observe
# Boot's real, unset-property default (true) and the startup warning that comes with it. The
# shared src/main/resources/application.yml sets open-in-view: false explicitly, which would
# suppress both the warning and the masking behaviour, so that file must not be in play here.
spring:
datasource:
url: jdbc:h2:mem:osiv-default-test;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
show_sql: true
logging:
level:
root: WARN
DEMO: INFO
org.springframework.boot.jpa.autoconfigure: WARN