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. * *

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. * *

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); } }