331 lines
13 KiB
Java
Executable File
331 lines
13 KiB
Java
Executable File
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}.
|
|
*
|
|
* <p>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();
|
|
}
|
|
}
|