Add Hibernate 7 batches 2-6 and batch 7: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, and Hibernate Validator CDI integration (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)
Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.Transaction;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Checks the original article's claim -- "Bulk HQL mutations bypass cache: a bulk update leaves
|
||||
* an already-cached entity stale until it is manually evicted" -- against what actually happens.
|
||||
* The claim is FALSE both for HQL/JPQL bulk statements and, once actually measured, for native
|
||||
* SQL bulk statements too: a fresh session sees the new value immediately in both cases, with no
|
||||
* manual eviction step needed for either.
|
||||
*
|
||||
* <p>For {@code Session.createMutationQuery(...).executeUpdate()}, verified by disassembling
|
||||
* {@code hibernate-core-7.4.5.Final.jar}: every bulk HQL/JPQL {@code update}/{@code delete}
|
||||
* registers a {@code org.hibernate.action.internal.BulkOperationCleanupAction} as an
|
||||
* after-transaction-completion process. Its {@code EntityCleanup} inner class calls {@code
|
||||
* EntityDataAccess.lockRegion()} then {@code EntityDataAccess.removeAll(session)} for every
|
||||
* entity type the statement's "query spaces" (tables) touch. Hibernate knows which entity types
|
||||
* are affected because {@code createMutationQuery} parses the HQL/JPQL itself.
|
||||
*
|
||||
* <p>The natural next question -- and the original hypothesis behind the second test below --
|
||||
* was whether a bulk update fired as raw/native SQL escapes this, since Hibernate's HQL parser
|
||||
* never sees it and so cannot name the affected "query spaces" for {@code
|
||||
* BulkOperationCleanupAction} to key off of. Measuring it disproves the hypothesis: Hibernate's
|
||||
* actual default for an unqualified native DML statement is the conservative opposite of "do
|
||||
* nothing" -- unable to prove which regions are safe, it invalidates every region it knows about.
|
||||
* The entity cache still ends up empty after the statement, just reached by a different path.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class BulkUpdateBypassesCacheTest {
|
||||
|
||||
private StandardServiceRegistry registry;
|
||||
private SessionFactory sessionFactory;
|
||||
|
||||
private void boot() {
|
||||
registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:bulkbypass;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.connection.password", "")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.generate_statistics", "true")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml")
|
||||
.build();
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
sessionFactory = metadata.buildSessionFactory();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (registry != null) {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkHqlUpdate_doesNotLeaveTheL2EntityCacheStale_becauseHibernateAutoEvictsTheRegion() {
|
||||
boot();
|
||||
|
||||
Long id;
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
|
||||
seed.persist(p);
|
||||
tx.commit();
|
||||
id = p.getId();
|
||||
}
|
||||
|
||||
// Populate/confirm the L2 region with the original price via a plain get() in its own session.
|
||||
try (Session warm = sessionFactory.openSession()) {
|
||||
CacheProduct p = warm.get(CacheProduct.class, id);
|
||||
assertThat(p.getPrice()).isEqualTo(1199.0);
|
||||
}
|
||||
|
||||
// Bulk HQL/JPQL UPDATE: Hibernate parses this itself, knows CacheProduct's table is a
|
||||
// "query space" the statement touches, and registers a BulkOperationCleanupAction that
|
||||
// evicts CacheProduct's entire L2 region right after this transaction commits.
|
||||
try (Session bulk = sessionFactory.openSession()) {
|
||||
Transaction tx = bulk.beginTransaction();
|
||||
int updated = bulk.createMutationQuery("update CacheProduct set price = :newPrice where id = :id")
|
||||
.setParameter("newPrice", 999.0)
|
||||
.setParameter("id", id)
|
||||
.executeUpdate();
|
||||
tx.commit();
|
||||
assertThat(updated).isEqualTo(1);
|
||||
}
|
||||
|
||||
// A brand-new session's get() -- does it see the new price, or the stale cached one?
|
||||
Double priceSeenAfterBulkUpdate;
|
||||
try (Session after = sessionFactory.openSession()) {
|
||||
priceSeenAfterBulkUpdate = after.get(CacheProduct.class, id).getPrice();
|
||||
}
|
||||
|
||||
System.out.println("RESULT[cache-bulk-update-hql-not-stale]: price in the database after the bulk HQL "
|
||||
+ "update=999.0 | price a brand-new session's get() actually returns=" + priceSeenAfterBulkUpdate
|
||||
+ " -- contrary to the original article, a bulk HQL update via createMutationQuery does NOT "
|
||||
+ "leave the L2 cache stale. BulkOperationCleanupAction evicts CacheProduct's entire region "
|
||||
+ "automatically once the statement's transaction commits, so no manual "
|
||||
+ "sessionFactory.getCache().evictEntityData(...) call is needed for this case.");
|
||||
|
||||
assertThat(priceSeenAfterBulkUpdate)
|
||||
.as("corrected finding: a fresh session sees the real 999.0 immediately -- the bulk HQL "
|
||||
+ "update did not leave a stale cache entry behind")
|
||||
.isEqualTo(999.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkNativeSqlUpdate_alsoDoesNotLeaveTheCacheStale_hibernateInvalidatesEverythingItCannotParse() {
|
||||
boot();
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
|
||||
Long id;
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
|
||||
seed.persist(p);
|
||||
tx.commit();
|
||||
id = p.getId();
|
||||
}
|
||||
|
||||
// Populate/confirm the L2 region with the original price via a plain get() in its own session.
|
||||
try (Session warm = sessionFactory.openSession()) {
|
||||
CacheProduct p = warm.get(CacheProduct.class, id);
|
||||
assertThat(p.getPrice()).isEqualTo(1199.0);
|
||||
}
|
||||
long l2PutsAfterWarm = stats.getSecondLevelCachePutCount();
|
||||
|
||||
// Raw/native SQL UPDATE: Hibernate's HQL parser never sees this statement, so it cannot
|
||||
// name the specific "query spaces" (tables) it touches the way createMutationQuery does.
|
||||
// The hypothesis going in was that this means NO cache eviction happens at all -- that
|
||||
// hypothesis is wrong, and disproven by the assertions below. Hibernate's actual behavior
|
||||
// for an unqualified native DML statement is the conservative opposite: since it cannot
|
||||
// prove which regions are safe, it treats every known cache region as a possibly-affected
|
||||
// query space and invalidates all of them, exactly as if the statement had touched
|
||||
// everything. This is confirmed here by getSecondLevelCacheHitCount() staying at zero on
|
||||
// the read after the native update, even though the entity was demonstrably cached going
|
||||
// in (l2PutsAfterWarm is 1, not 0).
|
||||
try (Session bulk = sessionFactory.openSession()) {
|
||||
Transaction tx = bulk.beginTransaction();
|
||||
int updated = bulk.createNativeQuery("update CacheProduct set price = :newPrice where id = :id")
|
||||
.setParameter("newPrice", 999.0)
|
||||
.setParameter("id", id)
|
||||
.executeUpdate();
|
||||
tx.commit();
|
||||
assertThat(updated).isEqualTo(1);
|
||||
}
|
||||
|
||||
long l2HitsBeforeRead = stats.getSecondLevelCacheHitCount();
|
||||
Double priceSeenAfterNativeUpdate;
|
||||
try (Session after = sessionFactory.openSession()) {
|
||||
priceSeenAfterNativeUpdate = after.get(CacheProduct.class, id).getPrice();
|
||||
}
|
||||
long l2HitsAfterRead = stats.getSecondLevelCacheHitCount();
|
||||
|
||||
System.out.println("RESULT[cache-bulk-update-native-not-stale]: price in the database after the native "
|
||||
+ "SQL update=999.0 | price a brand-new session's get() actually returns="
|
||||
+ priceSeenAfterNativeUpdate + " | L2 puts recorded before the native update=" + l2PutsAfterWarm
|
||||
+ " | L2 hits before/after the post-update read=" + l2HitsBeforeRead + "/" + l2HitsAfterRead
|
||||
+ " -- the original hypothesis (native SQL bypasses query-space tracking, so the L2 entry "
|
||||
+ "survives stale) was WRONG. The entity WAS cached (one L2 put), yet the post-update read is "
|
||||
+ "not an L2 hit at all: Hibernate cannot verify a native statement's affected tables, so it "
|
||||
+ "conservatively invalidates every region it knows about rather than none of them.");
|
||||
|
||||
assertThat(l2PutsAfterWarm)
|
||||
.as("the entity really was put into the L2 region by the warm-up get()")
|
||||
.isEqualTo(1L);
|
||||
assertThat(priceSeenAfterNativeUpdate)
|
||||
.as("corrected finding: a native SQL update does not leave the cache stale either -- "
|
||||
+ "Hibernate's conservative default invalidates the region instead")
|
||||
.isEqualTo(999.0);
|
||||
assertThat(l2HitsAfterRead)
|
||||
.as("and the read after the native update is not served from L2 at all -- it is a "
|
||||
+ "genuine re-query, not a lucky freshly-recomputed cache entry")
|
||||
.isEqualTo(l2HitsBeforeRead);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The original article claimed: "Without the jakarta classifier, Ehcache 3 ships with the older
|
||||
* javax.cache JCache API. Hibernate 7 requires the jakarta.cache namespace. Using the wrong
|
||||
* artifact causes a ClassNotFoundException or NoSuchMethodError at startup." That claim is
|
||||
* checked here directly against the classpath rather than repeated.
|
||||
*
|
||||
* <p>JSR-107 (JCache) was never migrated to the Jakarta namespace by its spec maintainers --
|
||||
* unlike JPA, Bean Validation, or Servlet. {@code javax.cache.Caching} is the one and only API
|
||||
* class, with or without Ehcache's own "jakarta" classifier. That classifier is Ehcache's own
|
||||
* internal choice of JAXB runtime major version (used to parse its own {@code ehcache.xml}), not
|
||||
* a JCache API namespace switch -- confirmed by comparing the two classifier jars' Gradle module
|
||||
* metadata (one depends on {@code jaxb-runtime [2.2,3)}, the other on {@code [3,3.1)}) and by
|
||||
* disassembling {@code ConfigurationParser.class} in both jars, which import
|
||||
* {@code javax.xml.bind} and {@code jakarta.xml.bind} respectively -- never {@code javax.cache}
|
||||
* or a {@code jakarta.cache} package, because the latter does not exist.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class CacheApiNamespaceTest {
|
||||
|
||||
@Test
|
||||
void javaxCacheApi_isOnTheClasspath_regardlessOfEhcachesJakartaClassifier() throws ClassNotFoundException {
|
||||
Class<?> caching = Class.forName("javax.cache.Caching");
|
||||
Class<?> cacheManager = Class.forName("javax.cache.CacheManager");
|
||||
|
||||
System.out.println("RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath "
|
||||
+ "(jar: " + caching.getProtectionDomain().getCodeSource().getLocation() + ")");
|
||||
|
||||
assertThat(caching).isNotNull();
|
||||
assertThat(cacheManager).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jakartaCacheNamespace_doesNotExist_onThisClasspathOrAnyOther() {
|
||||
Throwable thrown = catchClassNotFound("jakarta.cache.Cache");
|
||||
|
||||
System.out.println("RESULT[cache-api-no-jakarta-namespace]: Class.forName(\"jakarta.cache.Cache\") -> "
|
||||
+ thrown.getClass().getSimpleName()
|
||||
+ " -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's "
|
||||
+ "own \"jakarta\" classifier on org.ehcache:ehcache.");
|
||||
|
||||
assertThatThrownBy(() -> Class.forName("jakarta.cache.Cache"))
|
||||
.as("no jakarta.cache package has ever existed -- JCache (JSR-107) kept the javax.cache "
|
||||
+ "namespace even after Jakarta EE 9's javax->jakarta rename")
|
||||
.isInstanceOf(ClassNotFoundException.class);
|
||||
}
|
||||
|
||||
private static Throwable catchClassNotFound(String className) {
|
||||
try {
|
||||
Class.forName(className);
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.Transaction;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The ordinary case the original article's {@code Product} class was built around: PK lookups
|
||||
* of a {@code @Cacheable}/{@code @Cache}-annotated entity, served from Ehcache after the first
|
||||
* session closes, following the same isolated-SessionFactory pattern as chapter 06's
|
||||
* {@code NaturalIdL2CacheTest} so this never touches the shared Spring context.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class EntityL2CacheTest {
|
||||
|
||||
private StandardServiceRegistry registry;
|
||||
private SessionFactory sessionFactory;
|
||||
|
||||
private void boot() {
|
||||
registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:entityl2;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.connection.password", "")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.generate_statistics", "true")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml")
|
||||
.build();
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
sessionFactory = metadata.buildSessionFactory();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (registry != null) {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_fromABrandNewSession_isServedFromL2WithNoSql() {
|
||||
boot();
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
|
||||
Long id;
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
|
||||
seed.persist(p);
|
||||
tx.commit();
|
||||
id = p.getId();
|
||||
}
|
||||
|
||||
stats.clear();
|
||||
try (Session s1 = sessionFactory.openSession()) {
|
||||
CacheProduct p = s1.get(CacheProduct.class, id);
|
||||
assertThat(p.getName()).isEqualTo("Laptop Pro");
|
||||
}
|
||||
long queriesAfterSession1 = stats.getPrepareStatementCount();
|
||||
|
||||
try (Session s2 = sessionFactory.openSession()) {
|
||||
CacheProduct p = s2.get(CacheProduct.class, id);
|
||||
assertThat(p.getName()).isEqualTo("Laptop Pro");
|
||||
}
|
||||
long queriesAfterSession2 = stats.getPrepareStatementCount();
|
||||
long l2HitsAfterSession2 = stats.getSecondLevelCacheHitCount();
|
||||
|
||||
System.out.println("RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries="
|
||||
+ queriesAfterSession1 + " | session2 (brand-new session, same id) cumulative queries="
|
||||
+ queriesAfterSession2 + ", L2 entity cache hits=" + l2HitsAfterSession2);
|
||||
|
||||
// Surprise, and the same one chapter 06 already documented for @NaturalIdCache: persist()
|
||||
// + commit() populates the L2 entity cache region itself, before anyone ever calls get().
|
||||
// So "session1" here is ALREADY a cache hit, not a cold DB read -- there is no query left
|
||||
// for session1's own get() to save. This generalizes chapter 06's natural-id finding to
|
||||
// plain @Cacheable/@Cache entity caching too, not just @NaturalIdCache.
|
||||
assertThat(queriesAfterSession1)
|
||||
.as("persist()+commit() already populated the L2 region -- session1's get() fires no SQL")
|
||||
.isZero();
|
||||
assertThat(queriesAfterSession2)
|
||||
.as("session2, a brand-new session, must not fire any query either -- served from L2")
|
||||
.isEqualTo(queriesAfterSession1);
|
||||
assertThat(l2HitsAfterSession2)
|
||||
.as("both get() calls (session1 and session2) register as L2 entity cache hits")
|
||||
.isEqualTo(2L);
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Checks the original article's claim -- "Missing default-update-timestamps-region: Required for
|
||||
* the Query Cache -- omitting it causes startup errors" -- by actually omitting it, rather than
|
||||
* repeating the warning unverified. The claim is FALSE for this stack: {@code SessionFactory}
|
||||
* builds with no error at all.
|
||||
*
|
||||
* <p>{@code hibernate-jcache}'s default {@code MissingCacheStrategy} is {@code CREATE_WARN}
|
||||
* (external representation {@code "create-warn"}, confirmed by disassembling {@code
|
||||
* MissingCacheStrategy.class} in {@code hibernate-jcache-7.4.5.Final.jar}): a missing region is
|
||||
* created on the fly with provider-specific default policies, and Hibernate only logs {@code
|
||||
* HHH90001006}. Setting {@code hibernate.javax.cache.missing_cache_strategy} to {@code "fail"}
|
||||
* turns this into the hard startup error the article describes; {@code "create"} keeps the
|
||||
* auto-create behavior but silences the warning.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class MissingUpdateTimestampsRegionTest {
|
||||
|
||||
@Test
|
||||
void queryCacheEnabled_withNoUpdateTimestampsRegionInEhcacheXml_buildsFineWithOnlyAWarning() {
|
||||
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestamps;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.use_query_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18-missing-timestamps.xml")
|
||||
.build();
|
||||
try {
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
|
||||
Throwable thrown = catchThrowableFromBuildSessionFactory(metadata);
|
||||
|
||||
System.out.println("RESULT[cache-missing-timestamps-region]: "
|
||||
+ (thrown == null
|
||||
? "SessionFactory built with NO error -- contrary to the original article, the "
|
||||
+ "default MissingCacheStrategy (CREATE_WARN) auto-creates the missing "
|
||||
+ "default-update-timestamps-region and only logs HHH90001006"
|
||||
: thrown.getClass().getName() + ": " + rootMessage(thrown)));
|
||||
|
||||
assertThat(thrown)
|
||||
.as("corrected finding: with the default missing_cache_strategy, a missing "
|
||||
+ "default-update-timestamps-region does NOT fail SessionFactory startup")
|
||||
.isNull();
|
||||
} finally {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingMissingCacheStrategyToFail_reproducesTheHardStartupErrorTheArticleDescribed() {
|
||||
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestampsfail;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.use_query_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18-missing-timestamps.xml")
|
||||
.applySetting("hibernate.javax.cache.missing_cache_strategy", "fail")
|
||||
.build();
|
||||
try {
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
|
||||
Throwable thrown = catchThrowableFromBuildSessionFactory(metadata);
|
||||
|
||||
System.out.println("RESULT[cache-missing-timestamps-region-strict]: "
|
||||
+ "hibernate.javax.cache.missing_cache_strategy=fail -> "
|
||||
+ (thrown == null ? "SessionFactory built with NO error"
|
||||
: thrown.getClass().getName() + ": " + rootMessage(thrown))
|
||||
+ " -- this is how to opt into the hard-failure behavior the original article assumed "
|
||||
+ "was the default.");
|
||||
|
||||
assertThat(thrown)
|
||||
.as("with missing_cache_strategy explicitly set to fail, a missing "
|
||||
+ "default-update-timestamps-region does cause a startup error -- this is an "
|
||||
+ "opt-in, not the default")
|
||||
.isNotNull();
|
||||
} finally {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable catchThrowableFromBuildSessionFactory(Metadata metadata) {
|
||||
try {
|
||||
metadata.buildSessionFactory().close();
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable cause = t;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getMessage();
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.Transaction;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.query.Query;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Checks the original article's claim -- "Query Cache without Entity Cache causes N+1 selects" --
|
||||
* by actually measuring it rather than repeating it. The claim does NOT hold: with {@link
|
||||
* UncachedProduct} carrying no {@code @Cacheable}/{@code @Cache} at all, a second, brand-new
|
||||
* session repeating the same {@code setCacheable(true)} query fires ZERO SQL statements, not five.
|
||||
*
|
||||
* <p>The query cache region does not store only the row ids. It stores the full hydrated tuple
|
||||
* state of each result row (id plus every mapped column) at the moment the query first ran.
|
||||
* Hibernate reconstitutes {@code UncachedProduct} instances directly from that stored tuple data
|
||||
* on a cache hit -- no re-query of the rows, and (confirmed below via {@code
|
||||
* getSecondLevelCacheHitCount()}) no dependency on the entity's own L2 region at all, because
|
||||
* {@link UncachedProduct} does not have one. This test does not rule out an N+1 appearing for a
|
||||
* query that returns associations Hibernate must still initialize per row, or for a partial-hit
|
||||
* scenario after individual cache entries are evicted -- only the specific, simple case the
|
||||
* article described (a flat entity, no associations, a repeated identical query) is measured and
|
||||
* corrected here.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class QueryCacheWithoutEntityCacheTest {
|
||||
|
||||
private StandardServiceRegistry registry;
|
||||
private SessionFactory sessionFactory;
|
||||
|
||||
private void boot() {
|
||||
registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:querycachenoentity;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.connection.password", "")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.generate_statistics", "true")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.use_query_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml")
|
||||
.build();
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(UncachedProduct.class)
|
||||
.buildMetadata();
|
||||
sessionFactory = metadata.buildSessionFactory();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (registry != null) {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachedQuery_overUncachedEntities_repeatRunIsWorseThanNoCachingAtAll() {
|
||||
boot();
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
seed.persist(new UncachedProduct("Widget " + i));
|
||||
}
|
||||
tx.commit();
|
||||
}
|
||||
|
||||
String hql = "select p from UncachedProduct p order by p.id";
|
||||
|
||||
stats.clear();
|
||||
List<UncachedProduct> first;
|
||||
try (Session s1 = sessionFactory.openSession()) {
|
||||
Query<UncachedProduct> q = s1.createQuery(hql, UncachedProduct.class);
|
||||
q.setCacheable(true);
|
||||
first = q.list();
|
||||
}
|
||||
long queriesForFirstRun = stats.getPrepareStatementCount();
|
||||
long queryCacheMissesAfterFirst = stats.getQueryCacheMissCount();
|
||||
|
||||
stats.clear();
|
||||
List<UncachedProduct> second;
|
||||
try (Session s2 = sessionFactory.openSession()) {
|
||||
Query<UncachedProduct> q = s2.createQuery(hql, UncachedProduct.class);
|
||||
q.setCacheable(true);
|
||||
second = q.list();
|
||||
}
|
||||
long queriesForSecondRun = stats.getPrepareStatementCount();
|
||||
long queryCacheHitsAfterSecond = stats.getQueryCacheHitCount();
|
||||
long l2EntityHitsAfterSecond = stats.getSecondLevelCacheHitCount();
|
||||
|
||||
System.out.println("RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements="
|
||||
+ queriesForFirstRun + ", query-cache misses=" + queryCacheMissesAfterFirst
|
||||
+ " | second run (new session, query-cache HIT) SQL statements=" + queriesForSecondRun
|
||||
+ ", query-cache hits=" + queryCacheHitsAfterSecond
|
||||
+ ", L2 entity-cache hits=" + l2EntityHitsAfterSecond
|
||||
+ " -- contrary to the original article, the second run costs ZERO SQL statements: "
|
||||
+ "the query cache stores the full row tuples, not just the 5 ids, and Hibernate "
|
||||
+ "rebuilds UncachedProduct instances straight from that stored data. The zero L2 "
|
||||
+ "entity-cache hits confirm this does not depend on UncachedProduct having its own "
|
||||
+ "L2 region at all -- it has none.");
|
||||
|
||||
assertThat(first).hasSize(5);
|
||||
assertThat(second).hasSize(5);
|
||||
assertThat(queriesForFirstRun)
|
||||
.as("the first, cold run executes the query itself as ONE statement")
|
||||
.isEqualTo(1);
|
||||
assertThat(queryCacheHitsAfterSecond)
|
||||
.as("the second run IS a genuine query-cache hit")
|
||||
.isEqualTo(1L);
|
||||
assertThat(queriesForSecondRun)
|
||||
.as("corrected finding: the second run costs ZERO SQL statements, not the 5 individual "
|
||||
+ "SELECTs the original article claimed -- the query cache reconstitutes the "
|
||||
+ "entities directly from its own stored tuple data")
|
||||
.isZero();
|
||||
assertThat(l2EntityHitsAfterSecond)
|
||||
.as("and none of that reconstruction is an L2 entity-cache hit -- UncachedProduct has "
|
||||
+ "no L2 region to hit, so the mechanism is the query cache's own stored data, "
|
||||
+ "not a secretly-enabled entity cache")
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
+83
@@ -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();
|
||||
}
|
||||
}
|
||||
+62
@@ -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();
|
||||
}
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.ankurm.hibernatedemo.datetime;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "@Temporal verified".
|
||||
* The application context bootstrapping successfully with {@link TemporalOnJavaTimeEntity} on
|
||||
* the classpath is itself the finding: Hibernate 7.4.5 does NOT reject the misuse at boot.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class TemporalAnnotationTest {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
|
||||
@Autowired
|
||||
private EntityManagerFactory emf;
|
||||
|
||||
@Test
|
||||
void temporalAnnotationOnInstantIsSilentlyIgnored_bootSucceedsAndValueRoundTrips() {
|
||||
Instant now = Instant.parse("2026-05-20T09:15:30Z");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
TemporalOnJavaTimeEntity e = new TemporalOnJavaTimeEntity();
|
||||
e.setInstantWithTemporalAnnotation(now);
|
||||
em.persist(e);
|
||||
em.getTransaction().commit();
|
||||
Long id = e.getId();
|
||||
em.close();
|
||||
|
||||
EntityManager em2 = emf.createEntityManager();
|
||||
TemporalOnJavaTimeEntity loaded = em2.find(TemporalOnJavaTimeEntity.class, id);
|
||||
DEMO.info("@Temporal(TIMESTAMP) on Instant field: boot succeeded, round-tripped value = {} (expected {})",
|
||||
loaded.getInstantWithTemporalAnnotation(), now);
|
||||
em2.close();
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(loaded.getInstantWithTemporalAnnotation()).isEqualTo(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ankurm.hibernatedemo.datetime;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.TimeZone;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "The central experiment".
|
||||
* Stores an {@code OffsetDateTime} with offset {@code +05:30} through every
|
||||
* {@code @TimeZoneStorage} mode and logs exactly what offset comes back. Run this same test
|
||||
* under different {@code -Duser.timezone} values (see docs/output capture commands) to observe
|
||||
* the JVM-default-timezone hazard.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class TimeZoneStorageTest {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
|
||||
@Autowired
|
||||
private EntityManagerFactory emf;
|
||||
|
||||
@Test
|
||||
void storeNonUtcOffsetAndReadBackUnderEveryMode() {
|
||||
OffsetDateTime original = OffsetDateTime.of(2026, 6, 15, 14, 0, 0, 0, ZoneOffset.ofHoursMinutes(5, 30));
|
||||
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
TimeZoneStorageEntity e = new TimeZoneStorageEntity();
|
||||
e.setNoAnnotation(original);
|
||||
e.setNativeMode(original);
|
||||
e.setNormalizeMode(original);
|
||||
e.setNormalizeUtcMode(original);
|
||||
e.setColumnMode(original);
|
||||
e.setAutoMode(original);
|
||||
em.persist(e);
|
||||
em.getTransaction().commit();
|
||||
Long id = e.getId();
|
||||
em.close();
|
||||
|
||||
EntityManager em2 = emf.createEntityManager();
|
||||
TimeZoneStorageEntity loaded = em2.find(TimeZoneStorageEntity.class, id);
|
||||
|
||||
DEMO.info("JVM user.timezone system property = {}", System.getProperty("user.timezone"));
|
||||
DEMO.info("JVM TimeZone.getDefault() = {}", TimeZone.getDefault().getID());
|
||||
DEMO.info("ORIGINAL stored = {}", original);
|
||||
DEMO.info("TZ_MODE no-annotation (default) = {}", loaded.getNoAnnotation());
|
||||
DEMO.info("TZ_MODE NATIVE = {}", loaded.getNativeMode());
|
||||
DEMO.info("TZ_MODE NORMALIZE = {}", loaded.getNormalizeMode());
|
||||
DEMO.info("TZ_MODE NORMALIZE_UTC = {}", loaded.getNormalizeUtcMode());
|
||||
DEMO.info("TZ_MODE COLUMN = {}", loaded.getColumnMode());
|
||||
DEMO.info("TZ_MODE AUTO = {}", loaded.getAutoMode());
|
||||
em2.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.ankurm.hibernatedemo.hikari;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import java.sql.Connection;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The original article's "leak detection" advice was a single line -- "set
|
||||
* leakDetectionThreshold to catch connections that are opened but never closed" -- with no
|
||||
* mention of a floor value. Measuring it surfaces a trap the article missed entirely: HikariCP
|
||||
* silently disables leak detection for any threshold under 2000ms, rather than honoring a short
|
||||
* value for a fast test or a tight dev-loop check.
|
||||
*
|
||||
* <p>Both behaviors are confirmed by disassembling {@code HikariConfig.class} in {@code
|
||||
* HikariCP-7.0.2.jar}: {@code validate()} logs {@code "{} - leakDetectionThreshold is less than
|
||||
* 2000ms or more than maxLifetime, disabling it."} via {@code HikariConfig}'s own logger and
|
||||
* resets the field to {@code 0} (disabled) whenever the configured value is under 2000ms -- this
|
||||
* runs synchronously inside the {@code HikariDataSource} constructor, before any connection is
|
||||
* ever checked out.
|
||||
*
|
||||
* <p>Docs: docs/19-hikaricp-connection-pooling.md
|
||||
*/
|
||||
class HikariLeakDetectionTest {
|
||||
|
||||
private HikariDataSource dataSource;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (dataSource != null) {
|
||||
dataSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdUnder2000ms_isSilentlyDisabled_notShortenedToWhatWasAsked() {
|
||||
Logger configLogger = (Logger) LoggerFactory.getLogger(HikariConfig.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
configLogger.addAppender(appender);
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl("jdbc:h2:mem:hikarileakshort;DB_CLOSE_DELAY=-1");
|
||||
config.setUsername("sa");
|
||||
config.setLeakDetectionThreshold(500); // the trap: under the 2000ms floor
|
||||
|
||||
dataSource = new HikariDataSource(config); // validate() runs here, synchronously
|
||||
|
||||
System.out.println("RESULT[hikari-leak-threshold-floor]: requested leakDetectionThreshold=500ms | "
|
||||
+ "actual leakDetectionThreshold after construction=" + config.getLeakDetectionThreshold()
|
||||
+ "ms | logged warnings=" + appender.list.size()
|
||||
+ (appender.list.isEmpty() ? "" : " | message=" + appender.list.get(0).getFormattedMessage())
|
||||
+ " -- HikariCP does not clamp 500ms up to 2000ms, it disables leak detection "
|
||||
+ "entirely and logs a WARN naming the reason.");
|
||||
|
||||
assertThat(config.getLeakDetectionThreshold())
|
||||
.as("HikariConfig.validate() resets a sub-2000ms threshold to 0 (disabled), it does "
|
||||
+ "not round it up to the 2000ms floor")
|
||||
.isZero();
|
||||
assertThat(appender.list)
|
||||
.as("and it says so, once, at construction time")
|
||||
.hasSize(1);
|
||||
assertThat(appender.list.get(0).getFormattedMessage())
|
||||
.contains("leakDetectionThreshold is less than 2000ms");
|
||||
|
||||
configLogger.detachAppender(appender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdAtOrAbove2000ms_actuallyLogsAWarning_whenAConnectionIsHeldPastIt() throws Exception {
|
||||
Logger leakTaskLogger = (Logger) LoggerFactory.getLogger("com.zaxxer.hikari.pool.ProxyLeakTask");
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
leakTaskLogger.addAppender(appender);
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl("jdbc:h2:mem:hikarileak;DB_CLOSE_DELAY=-1");
|
||||
config.setUsername("sa");
|
||||
config.setMaximumPoolSize(2);
|
||||
config.setLeakDetectionThreshold(2000); // the floor itself -- the shortest value that "counts"
|
||||
dataSource = new HikariDataSource(config);
|
||||
|
||||
assertThat(config.getLeakDetectionThreshold())
|
||||
.as("2000ms is exactly at the floor, so unlike 500ms above, it is NOT disabled")
|
||||
.isEqualTo(2000L);
|
||||
|
||||
// Deliberately leaked: checked out and never closed within this test method.
|
||||
Connection leaked = dataSource.getConnection();
|
||||
|
||||
long deadline = System.currentTimeMillis() + 8000;
|
||||
while (appender.list.isEmpty() && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
assertThat(appender.list)
|
||||
.as("HikariCP's own leak-detection background task should have logged by now")
|
||||
.isNotEmpty();
|
||||
|
||||
String warning = appender.list.get(0).getFormattedMessage();
|
||||
System.out.println("RESULT[hikari-leak-detection]: leakDetectionThreshold=2000ms | logger="
|
||||
+ leakTaskLogger.getName() + " | level=" + appender.list.get(0).getLevel()
|
||||
+ " | message=" + warning);
|
||||
|
||||
assertThat(warning)
|
||||
.as("HikariCP's own leak-detection task logs a WARN naming the pool and the stack "
|
||||
+ "trace of where the connection was checked out, once it's been held longer "
|
||||
+ "than leakDetectionThreshold")
|
||||
.contains("Connection leak detection triggered");
|
||||
|
||||
leaked.close(); // clean up now that the test has what it needs
|
||||
leakTaskLogger.detachAppender(appender);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ankurm.hibernatedemo.hikari;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The failure mode a pool-sizing article should lead with: what actually happens when every
|
||||
* connection in the pool is checked out and something else asks for one more. Not "it blocks
|
||||
* forever" and not a generic timeout -- HikariCP throws a specific, named exception with the pool
|
||||
* name and the timeout value baked into the message.
|
||||
*
|
||||
* <p>Docs: docs/19-hikaricp-connection-pooling.md
|
||||
*/
|
||||
class HikariPoolExhaustionTest {
|
||||
|
||||
private HikariDataSource dataSource;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (dataSource != null) {
|
||||
dataSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestingOneMoreConnectionThanMaximumPoolSize_failsFastWithSqlTransientConnectionException()
|
||||
throws Exception {
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl("jdbc:h2:mem:hikariexhaustion;DB_CLOSE_DELAY=-1");
|
||||
config.setUsername("sa");
|
||||
config.setPoolName("exhaustion-pool");
|
||||
config.setMaximumPoolSize(1);
|
||||
config.setConnectionTimeout(1000); // fail fast for the test, default is 30000ms
|
||||
dataSource = new HikariDataSource(config);
|
||||
|
||||
// Hold the pool's one and only connection open.
|
||||
Connection held = dataSource.getConnection();
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
Throwable thrown = catchThrowable(() -> dataSource.getConnection());
|
||||
long waitedMs = System.currentTimeMillis() - start;
|
||||
|
||||
System.out.println("RESULT[hikari-pool-exhaustion]: maximumPoolSize=1, connectionTimeout=1000ms | "
|
||||
+ "second getConnection() waited=" + waitedMs + "ms before throwing "
|
||||
+ thrown.getClass().getName() + ": " + thrown.getMessage());
|
||||
|
||||
assertThat(thrown).isInstanceOf(SQLTransientConnectionException.class);
|
||||
assertThat(thrown.getMessage())
|
||||
.as("the exception message names the pool and states it's a connection-timeout, "
|
||||
+ "not a generic SQL error -- this is what to grep application logs for")
|
||||
.contains("exhaustion-pool")
|
||||
.contains("Connection is not available");
|
||||
assertThat(waitedMs)
|
||||
.as("the caller waits roughly connectionTimeout, not forever and not instantly")
|
||||
.isGreaterThanOrEqualTo(900);
|
||||
|
||||
held.close();
|
||||
|
||||
// With the held connection released, a new request succeeds immediately.
|
||||
try (Connection recovered = dataSource.getConnection()) {
|
||||
assertThat(recovered.isValid(1)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable catchThrowable(ThrowingCallable callable) {
|
||||
try {
|
||||
callable.call();
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingCallable {
|
||||
void call() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ankurm.hibernatedemo.hikari;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
|
||||
import org.hibernate.engine.spi.SessionFactoryImplementor;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The non-Spring side of this chapter: a plain {@code StandardServiceRegistry} -- no Spring Boot
|
||||
* autoconfiguration anywhere -- explicitly wired to HikariCP via {@code hibernate-hikaricp}
|
||||
* (the {@code org.hibernate.orm:hibernate-hikaricp} integration jar, added to this project's
|
||||
* {@code pom.xml} test scope specifically for this chapter). This is the style a Java SE
|
||||
* application, a batch job, or the original article's own non-Spring example would use.
|
||||
*
|
||||
* <p>{@code hibernate.connection.provider_class} accepts the short name {@code hikari} or {@code
|
||||
* hikaricp} (both registered, confirmed by disassembling {@code
|
||||
* StrategyRegistrationProviderImpl.class} in {@code hibernate-hikaricp-7.4.5.Final.jar}) as well
|
||||
* as the fully-qualified {@code org.hibernate.hikaricp.internal.HikariCPConnectionProvider}.
|
||||
* Every {@code hibernate.hikari.*}-prefixed setting (confirmed via the same jar's {@code
|
||||
* HikariConfigurationUtil.CONFIG_PREFIX} constant, {@code "hibernate.hikari."}) is copied
|
||||
* directly onto the underlying {@code HikariConfig} by property name -- {@code
|
||||
* hibernate.hikari.maximumPoolSize} sets {@code HikariConfig#setMaximumPoolSize}, {@code
|
||||
* hibernate.hikari.poolName} sets {@code HikariConfig#setPoolName}, and so on.
|
||||
*
|
||||
* <p>Docs: docs/19-hikaricp-connection-pooling.md
|
||||
*/
|
||||
class HikariRawBootstrapTest {
|
||||
|
||||
private StandardServiceRegistry registry;
|
||||
private SessionFactory sessionFactory;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (registry != null) {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawStandardServiceRegistry_wiredToHikariByShortName_actuallyUsesHikariUnderneath() {
|
||||
registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:hikarirawbootstrap;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.connection.provider_class", "hikari")
|
||||
.applySetting("hibernate.hikari.maximumPoolSize", "7")
|
||||
.applySetting("hibernate.hikari.poolName", "hibernate-demo-ch19-pool")
|
||||
.applySetting("hibernate.hikari.connectionTimeout", "5000")
|
||||
.build();
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(PoolProbe.class)
|
||||
.buildMetadata();
|
||||
sessionFactory = metadata.buildSessionFactory();
|
||||
|
||||
ConnectionProvider provider = sessionFactory.unwrap(SessionFactoryImplementor.class)
|
||||
.getServiceRegistry()
|
||||
.getService(ConnectionProvider.class);
|
||||
|
||||
System.out.println("RESULT[hikari-raw-bootstrap]: ConnectionProvider class="
|
||||
+ provider.getClass().getName()
|
||||
+ " | isUnwrappableAs(HikariDataSource)=" + provider.isUnwrappableAs(HikariDataSource.class));
|
||||
|
||||
assertThat(provider.getClass().getName())
|
||||
.as("hibernate.connection.provider_class=hikari resolves to the real HikariCP integration")
|
||||
.isEqualTo("org.hibernate.hikaricp.internal.HikariCPConnectionProvider");
|
||||
assertThat(provider.isUnwrappableAs(HikariDataSource.class)).isTrue();
|
||||
|
||||
HikariDataSource hikari = provider.unwrap(HikariDataSource.class);
|
||||
|
||||
System.out.println("RESULT[hikari-raw-bootstrap-config]: poolName=" + hikari.getPoolName()
|
||||
+ " | maximumPoolSize=" + hikari.getMaximumPoolSize()
|
||||
+ " | connectionTimeout=" + hikari.getConnectionTimeout() + "ms"
|
||||
+ " -- every value traces back to a hibernate.hikari.* setting passed into "
|
||||
+ "StandardServiceRegistryBuilder, with zero Spring involved.");
|
||||
|
||||
assertThat(hikari.getPoolName()).isEqualTo("hibernate-demo-ch19-pool");
|
||||
assertThat(hikari.getMaximumPoolSize()).isEqualTo(7);
|
||||
assertThat(hikari.getConnectionTimeout()).isEqualTo(5000L);
|
||||
|
||||
// Prove the pool is actually live, not just configured: persist through it.
|
||||
try (var session = sessionFactory.openSession()) {
|
||||
var tx = session.beginTransaction();
|
||||
session.persist(new PoolProbe("raw-bootstrap-probe"));
|
||||
tx.commit();
|
||||
}
|
||||
assertThat(hikari.getHikariPoolMXBean().getTotalConnections())
|
||||
.as("the pool actually opened at least one real connection to service that persist()")
|
||||
.isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.hibernatedemo.hikari;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.hibernatedemo.HibernateDemoApplication;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* Checks the original article's opening claim -- "Spring Boot uses HikariCP by default since 2.0"
|
||||
* -- against the actual bean in this project's own Spring context, rather than repeating the
|
||||
* version number from memory. `application.yml` in this repo names no connection-pool type at
|
||||
* all: no {@code spring.datasource.type}, no {@code spring.datasource.hikari.*} block, nothing.
|
||||
* The {@code DataSource} bean Spring Boot 4.1.1 hands out anyway is a real {@code
|
||||
* HikariDataSource} -- confirmed here, not assumed.
|
||||
*
|
||||
* <p>Docs: docs/19-hikaricp-connection-pooling.md
|
||||
*/
|
||||
@SpringBootTest(classes = HibernateDemoApplication.class)
|
||||
class SpringAutoConfiguredHikariTest {
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void springBootDataSourceBean_isHikariByDefault_withNoExplicitPoolConfiguration() {
|
||||
assertThat(dataSource)
|
||||
.as("with no spring.datasource.type and no spring.datasource.hikari.* block "
|
||||
+ "anywhere in application.yml, Spring Boot's own default connection pool "
|
||||
+ "choice is still HikariCP")
|
||||
.isInstanceOf(HikariDataSource.class);
|
||||
|
||||
HikariDataSource hikari = (HikariDataSource) dataSource;
|
||||
|
||||
System.out.println("RESULT[hikari-spring-default]: dataSource class=" + dataSource.getClass().getName()
|
||||
+ " | pool name=" + hikari.getPoolName()
|
||||
+ " | maximumPoolSize=" + hikari.getMaximumPoolSize()
|
||||
+ " | minimumIdle=" + hikari.getMinimumIdle()
|
||||
+ " | connectionTimeout=" + hikari.getConnectionTimeout() + "ms"
|
||||
+ " | idleTimeout=" + hikari.getIdleTimeout() + "ms"
|
||||
+ " -- these are HikariCP's own built-in defaults (maximumPoolSize=10, "
|
||||
+ "minimumIdle defaults to maximumPoolSize), not anything this project set.");
|
||||
|
||||
assertThat(hikari.getMaximumPoolSize())
|
||||
.as("HikariCP's own documented default pool size, unless overridden")
|
||||
.isEqualTo(10);
|
||||
assertThat(hikari.getPoolName())
|
||||
.as("with no spring.datasource.hikari.pool-name set, Spring Boot lets HikariCP "
|
||||
+ "generate its own default name (HikariPool-N)")
|
||||
.startsWith("HikariPool-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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 <natural-id> 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());
|
||||
}
|
||||
}
|
||||
+56
@@ -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.");
|
||||
}
|
||||
}
|
||||
+46
@@ -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;
|
||||
|
||||
/**
|
||||
* <xml-mapping-metadata-complete/> 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 <basic name="value"> and omits <id> 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");
|
||||
}
|
||||
}
|
||||
+159
@@ -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();
|
||||
}
|
||||
}
|
||||
+86
@@ -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();
|
||||
}
|
||||
}
|
||||
+92
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -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();
|
||||
}
|
||||
}
|
||||
+88
@@ -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.
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
+160
@@ -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);
|
||||
}
|
||||
}
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+59
@@ -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();
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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");
|
||||
}
|
||||
}
|
||||
Executable
+45
@@ -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.");
|
||||
}
|
||||
}
|
||||
+297
@@ -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);
|
||||
}
|
||||
}
|
||||
+96
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -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();
|
||||
}
|
||||
}
|
||||
+65
@@ -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");
|
||||
}
|
||||
}
|
||||
+95
@@ -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();
|
||||
}
|
||||
}
|
||||
+115
@@ -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");
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.hibernatedemo.testdb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.engine.spi.SessionFactoryImplementor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Merely having hibernate-jcache on the classpath is enough to turn the second-level cache ON,
|
||||
* with nothing configured. Hibernate 7.4.5 resolves a RegionFactory through the service loader
|
||||
* and enables L2 caching on the strength of finding one.
|
||||
*
|
||||
* <p>That is why {@code application.yml} in this repository pins
|
||||
* {@code hibernate.cache.use_second_level_cache: false} explicitly. Before it did, a standalone
|
||||
* test closing the shared Ehcache CacheManager broke an unrelated Spring test's commit with
|
||||
* {@code Cache[...] is closed} -- see docs/output/testdb-jcache-classpath-pollution.txt.
|
||||
*
|
||||
* <p>This test boots a bare SessionFactory with no cache settings at all, so it still observes
|
||||
* the raw Hibernate default rather than this project's pinned override.
|
||||
*
|
||||
* <p>Docs: docs/09-testing-in-memory-databases.md
|
||||
*/
|
||||
class JCacheOnClasspathAutoEnablesL2Test {
|
||||
|
||||
@Test
|
||||
void jcacheOnTheClasspath_enablesSecondLevelCaching_withNothingConfigured() {
|
||||
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:jcacheprobe")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create-drop")
|
||||
.build();
|
||||
try (SessionFactory sf = new MetadataSources(registry)
|
||||
.addAnnotatedClass(TestDbWidget.class)
|
||||
.buildMetadata()
|
||||
.buildSessionFactory()) {
|
||||
|
||||
SessionFactoryImplementor impl = sf.unwrap(SessionFactoryImplementor.class);
|
||||
boolean l2Enabled = impl.getSessionFactoryOptions().isSecondLevelCacheEnabled();
|
||||
String regionFactory = impl.getCache().getRegionFactory().getClass().getName();
|
||||
|
||||
System.out.println("nothing configured about caching anywhere in this bootstrap");
|
||||
System.out.println("second-level cache enabled = " + l2Enabled);
|
||||
System.out.println("region factory = " + regionFactory);
|
||||
|
||||
assertThat(l2Enabled).isTrue();
|
||||
assertThat(regionFactory).contains("JCacheRegionFactory");
|
||||
} finally {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ankurm.hibernatedemo.testdb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Backs docs/09-testing-in-memory-databases.md, chapter "Schema isolation: create-drop vs rollback vs
|
||||
* DB_CLOSE_DELAY". Uses one shared {@link EntityManagerFactory} across two ordered tests to
|
||||
* PROVE the pollution, then a second, correctly-isolated pair to prove the fix -- both against
|
||||
* plain H2 (the pollution mechanism is not H2-specific, but H2 is what the rest of this repo
|
||||
* already uses for its default datasource).
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class SchemaIsolationTest {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
private static SessionFactory pollutedSf;
|
||||
|
||||
@BeforeAll
|
||||
static void openSharedFactory() {
|
||||
// DB_CLOSE_DELAY=-1 is what keeps this in-memory H2 database alive across the many
|
||||
// short-lived connections each Session opens and closes -- without it, H2 tears the
|
||||
// whole in-memory database down the moment the LAST open connection closes, and the
|
||||
// next openSession() call would silently reconnect to a brand-new, empty database
|
||||
// instead of failing loudly.
|
||||
pollutedSf = TestDbSupport.buildSessionFactory(
|
||||
TestDbSupport.Db.H2, "isolation-demo", "create-drop", TestDbWidget.class);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void closeSharedFactory() {
|
||||
pollutedSf.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void testA_insertsARowWithNoCleanup() {
|
||||
Session session = pollutedSf.openSession();
|
||||
session.beginTransaction();
|
||||
session.persist(new TestDbWidget("AAAAA", 1, true, "left behind by testA"));
|
||||
session.getTransaction().commit();
|
||||
session.close();
|
||||
DEMO.info("testA committed a row and did nothing to clean it up");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void testB_seesTestAsRow_becauseThereWasNoIsolationBetweenThem() {
|
||||
Session session = pollutedSf.openSession();
|
||||
long count = session.createQuery("select count(w) from TestDbWidget w", Long.class).getSingleResult();
|
||||
session.close();
|
||||
DEMO.info("testB sees {} row(s) -- at least one is testA's leftover, proving pollution when isolation is skipped", count);
|
||||
// The point: testB did not insert anything itself yet, but the row count is already
|
||||
// >= 1, because testA's commit was never rolled back or dropped.
|
||||
assertThat(count).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
// ---- Now the same shape, but done correctly with @Transactional-style manual rollback ----
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void testC_insertsARow_thenRollsBackInsteadOfCommitting() {
|
||||
Session session = pollutedSf.openSession();
|
||||
session.beginTransaction();
|
||||
session.persist(new TestDbWidget("BBBBB", 2, true, "rolled back by testC, must not survive"));
|
||||
long duringTxn = session.createQuery("select count(w) from TestDbWidget w", Long.class).getSingleResult();
|
||||
session.getTransaction().rollback();
|
||||
session.close();
|
||||
DEMO.info("testC saw {} row(s) DURING its own transaction (includes its own uncommitted insert), then rolled back", duringTxn);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void testD_doesNotSeeTestCsRolledBackRow() {
|
||||
Session session = pollutedSf.openSession();
|
||||
long count = session.createQuery(
|
||||
"select count(w) from TestDbWidget w where w.sku = :sku", Long.class)
|
||||
.setParameter("sku", "BBBBB")
|
||||
.getSingleResult();
|
||||
session.close();
|
||||
DEMO.info("testD looks specifically for testC's SKU 'BBBBB' -- found {} (must be 0: rollback isolated it)", count);
|
||||
assertThat(count).isZero();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.hibernatedemo.testdb;
|
||||
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Backs docs/09-testing-in-memory-databases.md, chapter "Startup time, with the caveat this is a shared
|
||||
* container". NOT a benchmark -- three runs per database, plain {@code System.nanoTime()},
|
||||
* one shared, likely-noisy sandbox container. The numbers are indicative of relative order of
|
||||
* magnitude only, and the docs/output file this writes says so explicitly.
|
||||
*/
|
||||
class StartupTimingTest {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
|
||||
private long timeOneBuild(TestDbSupport.Db db, String suffix) {
|
||||
long start = System.nanoTime();
|
||||
SessionFactory sf = TestDbSupport.buildSessionFactory(db, suffix, "create-drop", TestDbWidget.class);
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
sf.close();
|
||||
return elapsedMs;
|
||||
}
|
||||
|
||||
@Test
|
||||
void measureStartupAcrossThreeRunsPerDatabase() {
|
||||
TestDbSupport.Db[] dbs = {TestDbSupport.Db.H2, TestDbSupport.Db.HSQLDB, TestDbSupport.Db.DERBY};
|
||||
for (TestDbSupport.Db db : dbs) {
|
||||
long[] times = new long[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
times[i] = timeOneBuild(db, "timing" + i);
|
||||
}
|
||||
DEMO.info("startup ms for {} over 3 runs: {}, {}, {} (sandbox container -- indicative only, not a benchmark)",
|
||||
db, times[0], times[1], times[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ankurm.hibernatedemo.testdb;
|
||||
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
|
||||
/**
|
||||
* Shared plumbing for docs/09-testing-in-memory-databases.md. Builds a plain Hibernate {@link SessionFactory} --
|
||||
* no Spring involved -- against one of the three databases, with the SAME {@link TestDbWidget}
|
||||
* mapping every time. Kept deliberately outside Spring Boot's own datasource auto-configuration
|
||||
* so each database's dialect resolution, generated DDL, and generator behaviour can be observed
|
||||
* directly and independently, one at a time.
|
||||
*/
|
||||
final class TestDbSupport {
|
||||
|
||||
enum Db {
|
||||
H2("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1", "org.h2.Driver", "sa", ""),
|
||||
H2_POSTGRES_MODE("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", "org.h2.Driver", "sa", ""),
|
||||
H2_ORACLE_MODE("jdbc:h2:mem:testdb-%s;DB_CLOSE_DELAY=-1;MODE=Oracle", "org.h2.Driver", "sa", ""),
|
||||
HSQLDB("jdbc:hsqldb:mem:testdb-%s", "org.hsqldb.jdbc.JDBCDriver", "SA", ""),
|
||||
DERBY("jdbc:derby:memory:testdb-%s;create=true", "org.apache.derby.jdbc.EmbeddedDriver", "APP", "APP");
|
||||
|
||||
final String urlTemplate;
|
||||
final String driver;
|
||||
final String user;
|
||||
final String password;
|
||||
|
||||
Db(String urlTemplate, String driver, String user, String password) {
|
||||
this.urlTemplate = urlTemplate;
|
||||
this.driver = driver;
|
||||
this.user = user;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
String url(String schemaSuffix) {
|
||||
return urlTemplate.formatted(schemaSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
private TestDbSupport() {
|
||||
}
|
||||
|
||||
static StandardServiceRegistry buildRegistry(Db db, String schemaSuffix, String hbm2ddl) {
|
||||
StandardServiceRegistryBuilder builder = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", db.driver)
|
||||
.applySetting("hibernate.connection.url", db.url(schemaSuffix))
|
||||
.applySetting("hibernate.connection.username", db.user)
|
||||
.applySetting("hibernate.connection.password", db.password)
|
||||
.applySetting("hibernate.hbm2ddl.auto", hbm2ddl)
|
||||
.applySetting("hibernate.show_sql", "true")
|
||||
.applySetting("hibernate.format_sql", "false");
|
||||
if (db == Db.DERBY) {
|
||||
// Hibernate 7 removed Derby dialect support from hibernate-core. Auto-detection
|
||||
// against a live Derby connection fails outright ("Unable to determine Dialect for
|
||||
// Apache Derby 10.16"), and the OLD FQCN org.hibernate.dialect.DerbyDialect no
|
||||
// longer exists either -- it moved to the separate hibernate-community-dialects
|
||||
// artifact under a NEW package, org.hibernate.community.dialect.DerbyDialect.
|
||||
// See docs/output/testdb-derby-dialect-not-found.txt for both failures, verbatim.
|
||||
builder.applySetting("hibernate.dialect", "org.hibernate.community.dialect.DerbyDialect");
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
static SessionFactory buildSessionFactory(Db db, String schemaSuffix, String hbm2ddl, Class<?>... entities) {
|
||||
StandardServiceRegistry registry = buildRegistry(db, schemaSuffix, hbm2ddl);
|
||||
MetadataSources sources = new MetadataSources(registry);
|
||||
for (Class<?> e : entities) {
|
||||
sources.addAnnotatedClass(e);
|
||||
}
|
||||
Metadata metadata = sources.buildMetadata();
|
||||
return metadata.buildSessionFactory();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ankurm.hibernatedemo.validation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.Validator;
|
||||
import java.util.Set;
|
||||
import org.jboss.weld.environment.se.Weld;
|
||||
import org.jboss.weld.environment.se.WeldContainer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The other half of the measurement {@link PlainValidationNoCdiTest} started: run the identical
|
||||
* {@link StockLevel}/{@link PositiveInventoryValidator} pair inside a real, standalone CDI
|
||||
* container (Weld SE -- no Jakarta EE server) with {@code hibernate-validator-cdi} on the
|
||||
* classpath, and see whether {@code @Inject} actually works this time.
|
||||
*
|
||||
* <p>It does, and the mechanism is worth naming rather than treating as magic: {@code
|
||||
* hibernate-validator-cdi-9.1.3.Final.jar} registers {@code
|
||||
* org.hibernate.validator.cdi.ValidationExtension} as a {@code jakarta.enterprise.inject.spi.Extension}
|
||||
* (confirmed via its {@code META-INF/services/jakarta.enterprise.inject.spi.Extension} file).
|
||||
* Once Weld picks that extension up, it contributes CDI beans for {@code Validator}/{@code
|
||||
* ValidatorFactory} whose {@code ConstraintValidatorFactory} is {@code
|
||||
* org.hibernate.validator.cdi.spi.InjectingConstraintValidatorFactory} -- a factory that builds
|
||||
* each {@code ConstraintValidator} instance through the CDI {@code BeanManager} instead of plain
|
||||
* reflection, so {@code @Inject} fields on it are resolved like any other managed bean's.
|
||||
*
|
||||
* <p>Docs: docs/20-hibernate-validator-cdi.md
|
||||
*/
|
||||
class CdiValidationTest {
|
||||
|
||||
private WeldContainer container;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (container != null) {
|
||||
container.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void constraintValidatorsInjectField_isProperlyPopulated_insideARunningCdiContainer() {
|
||||
container = new Weld().initialize();
|
||||
Validator validator = container.select(Validator.class).get();
|
||||
|
||||
Set<ConstraintViolation<StockLevel>> belowThreshold = validator.validate(new StockLevel(3));
|
||||
Set<ConstraintViolation<StockLevel>> atThreshold = validator.validate(new StockLevel(5));
|
||||
Set<ConstraintViolation<StockLevel>> aboveThreshold = validator.validate(new StockLevel(10));
|
||||
|
||||
System.out.println("RESULT[cdi-validation-injection-works]: validator obtained from a running "
|
||||
+ "Weld SE container | StockLevel(3) violations=" + belowThreshold.size()
|
||||
+ " | StockLevel(5) violations=" + atThreshold.size()
|
||||
+ " | StockLevel(10) violations=" + aboveThreshold.size()
|
||||
+ " -- InventoryPolicy.minimumThreshold()=5 was actually injected and actually used, "
|
||||
+ "no NullPointerException anywhere.");
|
||||
|
||||
assertThat(belowThreshold)
|
||||
.as("3 is below the injected policy's threshold of 5 -- a real constraint violation, "
|
||||
+ "not an exception")
|
||||
.hasSize(1);
|
||||
assertThat(atThreshold)
|
||||
.as("5 meets the threshold exactly")
|
||||
.isEmpty();
|
||||
assertThat(aboveThreshold)
|
||||
.as("10 comfortably clears the threshold")
|
||||
.isEmpty();
|
||||
|
||||
assertThat(belowThreshold.iterator().next().getMessage())
|
||||
.isEqualTo("quantity is below the minimum inventory threshold");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.ankurm.hibernatedemo.validation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.Validator;
|
||||
import jakarta.validation.ValidatorFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The baseline this chapter needs before the CDI test means anything: plain Jakarta Bean
|
||||
* Validation, bootstrapped the ordinary way with no CDI container running at all. {@code
|
||||
* Validation.buildDefaultValidatorFactory()} builds its {@code ConstraintValidatorFactory} by
|
||||
* plain reflection ({@code Class.newInstance()}-equivalent) -- it does not scan for {@code
|
||||
* @Inject} at all, so {@link PositiveInventoryValidator#policy} is left {@code null}.
|
||||
*
|
||||
* <p>Docs: docs/20-hibernate-validator-cdi.md
|
||||
*/
|
||||
class PlainValidationNoCdiTest {
|
||||
|
||||
@Test
|
||||
void constraintValidatorsInjectField_isNeverPopulated_withoutACdiContainer() {
|
||||
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
|
||||
Validator validator = factory.getValidator();
|
||||
|
||||
Throwable thrown = catchThrowable(() -> validator.validate(new StockLevel(3)));
|
||||
|
||||
System.out.println("RESULT[cdi-plain-validation-no-injection]: validating StockLevel(3) with "
|
||||
+ "Validation.buildDefaultValidatorFactory() (no CDI container running) throws "
|
||||
+ describeChain(thrown));
|
||||
|
||||
assertThat(thrown).isNotNull();
|
||||
Throwable rootCause = rootCause(thrown);
|
||||
assertThat(rootCause)
|
||||
.as("the @Inject-annotated policy field was never populated, so calling "
|
||||
+ "policy.minimumThreshold() inside isValid() throws a plain NullPointerException "
|
||||
+ "-- not a validation failure, a validator bug caused by the missing CDI container")
|
||||
.isInstanceOf(NullPointerException.class);
|
||||
|
||||
factory.close();
|
||||
}
|
||||
|
||||
private static Throwable catchThrowable(ThrowingRunnable runnable) {
|
||||
try {
|
||||
runnable.run();
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable rootCause(Throwable t) {
|
||||
Throwable cause = t;
|
||||
while (cause.getCause() != null && cause.getCause() != cause) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause;
|
||||
}
|
||||
|
||||
private static String describeChain(Throwable t) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Throwable current = t;
|
||||
while (current != null) {
|
||||
sb.append(current.getClass().getName());
|
||||
if (current.getCause() != null && current.getCause() != current) {
|
||||
sb.append(" -> caused by ");
|
||||
}
|
||||
current = current.getCause() == current ? null : current.getCause();
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingRunnable {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user