Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
298 lines
14 KiB
Java
Executable File
298 lines
14 KiB
Java
Executable File
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);
|
|
}
|
|
}
|