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}. * *
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(); } }