Add Hibernate 7 batches 2-6: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API and EntityManager bootstrapping (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
2026-09-18 18:23:51 +00:00
committed by Claude
co-authored by Claude Sonnet 5
commit 98bb6b4ce4
246 changed files with 19120 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,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,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,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,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,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();
}
}