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:
+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user