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

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