1
0

Add hibernate-demo: get() vs load(), merge() vs refresh(), inserting objects (Hibernate 7.4.1.Final + Spring Boot 4.1.0)

Adds a JUnit test suite (GetVsGetReferenceTest, MergeRefreshTest, OptimisticLockTest,
IdentityBatchTest, SequenceBatchTest, AllocationSizeSweepTest, BatchSizeSweepTest) so every
surprising behavior described in the three companion posts has a reproducible test, alongside
the original CommandLineRunner scenarios. Rewrites all three doc chapters and the README around
the new experiments: the get()/getReference() same-session matrix, the merge()/refresh()
experiments (including exactly when OptimisticLockException surfaces and a corrected LAZY-plus-
cascade merge() result), and two new sweeps (allocationSize, batch_size) for batch inserts.
This commit is contained in:
2026-08-26 17:26:04 +00:00
commit 7b06d653e4
43 changed files with 3053 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
package com.ankurm.hibernatedemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Entry point for the companion demos behind three ankurm.com Hibernate 7 posts:
*
* <ul>
* <li>{@code get-vs-load} &mdash; docs/01-get-vs-load.md, {@link com.ankurm.hibernatedemo.scenario.GetVsLoadRunner}</li>
* <li>{@code merge-vs-refresh} &mdash; docs/02-merge-vs-refresh.md, {@link com.ankurm.hibernatedemo.scenario.MergeVsRefreshRunner}</li>
* <li>{@code insert-identity} / {@code insert-sequence} &mdash; docs/03-inserting-objects.md,
* {@link com.ankurm.hibernatedemo.scenario.InsertIdentityRunner} and
* {@link com.ankurm.hibernatedemo.scenario.InsertSequenceRunner}</li>
* </ul>
*
* Each scenario is a profile-gated {@link org.springframework.boot.CommandLineRunner} that runs
* once against an in-memory H2 database and exits &mdash; there is no web server to keep alive,
* so {@code scripts/run.sh &lt;profile&gt;} is a plain foreground {@code mvn spring-boot:run} call.
*/
@SpringBootApplication
public class HibernateDemoApplication {
public static void main(String[] args) {
SpringApplication.run(HibernateDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,97 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import java.util.ArrayList;
import java.util.List;
/**
* The entity used by {@code get-vs-load} and {@code merge-vs-refresh}.
*
* <p>Docs: docs/01-get-vs-load.md, docs/02-merge-vs-refresh.md.
*
* <p>Carries a {@code @Version} column on purpose &mdash; the merge/refresh tests need a real
* optimistic-lock field to show what merge() does when the version it is holding is stale, not
* just what it does to a plain column. The {@code status} field exists specifically for
* {@code MergeRefreshTest#refreshDiscardsUnflushedEditSilently}, framed as the
* &quot;USER_EDIT&quot; vs &quot;ADMIN_EDIT&quot; scenario in docs/02-merge-vs-refresh.md. The
* {@code notes} collection is LAZY and cascades MERGE only &mdash; it exists solely for
* {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized}, which shows that an
* unfetched collection is never navigated during merge().
*/
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
private Long id;
private String title;
private String author;
private String status;
@Version
private Long version;
@OneToMany(mappedBy = "book", cascade = CascadeType.MERGE, fetch = FetchType.LAZY)
private List<Note> notes = new ArrayList<>();
protected Book() {
// JPA
}
public Book(String title, String author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Long getVersion() {
return version;
}
public List<Note> getNotes() {
return notes;
}
@Override
public String toString() {
return "Book{id=%s, title=%s, author=%s, status=%s, version=%s}"
.formatted(id, title, author, status, version);
}
}

View File

@@ -0,0 +1,45 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
/**
* Child of {@link Book}, used only by {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized}
* (docs/02-merge-vs-refresh.md) to show that an unfetched {@code LAZY} collection is not
* navigated -- and therefore cannot fail with {@code LazyInitializationException} -- during
* {@code merge()} of the owning detached entity.
*/
@Entity
public class Note {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String text;
@ManyToOne
@JoinColumn(name = "book_id")
private Book book;
protected Note() {
// JPA
}
public Note(String text, Book book) {
this.text = text;
this.book = book;
}
public Long getId() {
return id;
}
public String getText() {
return text;
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* One of four otherwise-identical entities ({@link WidgetAlloc1}, {@link WidgetAlloc10},
* {@link WidgetAlloc25}, {@link WidgetAlloc50}) used only by
* {@code AllocationSizeSweepTest} to isolate the effect of {@code allocationSize} on
* {@code prepareStatementCount} while {@code hibernate.jdbc.batch_size} is held fixed at 25.
* Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc1 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc1_seq")
@SequenceGenerator(name = "widget_alloc1_seq", sequenceName = "widget_alloc1_seq", allocationSize = 1)
private Long id;
private String name;
protected WidgetAlloc1() {
// JPA
}
public WidgetAlloc1(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,34 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* See {@link WidgetAlloc1} &mdash; same purpose, {@code allocationSize = 10}.
* Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc10 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc10_seq")
@SequenceGenerator(name = "widget_alloc10_seq", sequenceName = "widget_alloc10_seq", allocationSize = 10)
private Long id;
private String name;
protected WidgetAlloc10() {
// JPA
}
public WidgetAlloc10(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* See {@link WidgetAlloc1} &mdash; same purpose, {@code allocationSize = 25} (matches
* {@code hibernate.jdbc.batch_size} in the sweep, same as {@link WidgetSequence}).
* Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc25 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc25_seq")
@SequenceGenerator(name = "widget_alloc25_seq", sequenceName = "widget_alloc25_seq", allocationSize = 25)
private Long id;
private String name;
protected WidgetAlloc25() {
// JPA
}
public WidgetAlloc25(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* See {@link WidgetAlloc1} &mdash; same purpose, {@code allocationSize = 50} (JPA's default).
* Reused by {@code BatchSizeSweepTest} to hold {@code allocationSize} fixed at 50 while
* {@code hibernate.jdbc.batch_size} varies. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetAlloc50 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc50_seq")
@SequenceGenerator(name = "widget_alloc50_seq", sequenceName = "widget_alloc50_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetAlloc50() {
// JPA
}
public WidgetAlloc50(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize1}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep1 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep1() {
// JPA
}
public WidgetBatchSweep1(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize10}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep10 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep10() {
// JPA
}
public WidgetBatchSweep10(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize25}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep25 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep25() {
// JPA
}
public WidgetBatchSweep25(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Used exclusively by {@code BatchSizeSweepTest.BatchSize50}. See {@link WidgetBatchSweep50}'s
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
*/
@Entity
public class WidgetBatchSweep50 {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
private Long id;
private String name;
protected WidgetBatchSweep50() {
// JPA
}
public WidgetBatchSweep50(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}

View File

@@ -0,0 +1,41 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
/**
* Insert-scenario twin of {@link WidgetSequence}, identical except for the id generation
* strategy. Docs: docs/03-inserting-objects.md.
*
* <p>{@code IDENTITY} requires the database to hand back the generated key on every single
* insert, which is exactly why it defeats JDBC batching &mdash; see the captured output in
* docs/output/insert-identity.txt versus docs/output/insert-sequence.txt for the same
* {@code hibernate.jdbc.batch_size} setting producing very different behaviour.
*/
@Entity
public class WidgetIdentity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected WidgetIdentity() {
// JPA
}
public WidgetIdentity(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,42 @@
package com.ankurm.hibernatedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
/**
* Insert-scenario twin of {@link WidgetIdentity}. Docs: docs/03-inserting-objects.md.
*
* <p>{@code allocationSize} matches {@code hibernate.jdbc.batch_size} in
* {@code application-insert-sequence.yml} on purpose: a mismatched allocation size is its own
* classic footgun (extra round trips to refill the sequence pool mid-batch) and not one this
* repo is trying to demonstrate here.
*/
@Entity
public class WidgetSequence {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_seq")
@SequenceGenerator(name = "widget_seq", sequenceName = "widget_seq", allocationSize = 25)
private Long id;
private String name;
protected WidgetSequence() {
// JPA
}
public WidgetSequence(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,154 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4859 ("Hibernate 7: get() vs load()") and
* docs/01-get-vs-load.md. Captured verbatim into docs/output/get-vs-load.txt by
* {@code scripts/run.sh getvsload}.
*
* <p>Each step opens its own {@link EntityManager} deliberately, so the SQL log lines that
* bracket a step are unambiguously that step's own traffic &mdash; there is no shared session
* whose first-level cache could quietly answer a later {@code get()} for free.
*/
@Component
@Profile("getvsload")
public class GetVsLoadRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private final EntityManagerFactory emf;
public GetVsLoadRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
Long existingId = seedOneBook();
long missingId = existingId + 999_000L;
step1_getExisting(existingId);
step2_getMissing(missingId);
step3_getReferenceExisting_noSelectUntilAccessed(existingId);
step4_getReferenceMissing_exceptionOnlyOnAccess(missingId);
step5_getReferenceThenSessionClosed_lazyInitException(existingId);
step6_proxyVsRealIdentity(existingId);
}
private Long seedOneBook() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book("Effective Java", "Joshua Bloch");
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
DEMO.info("SEED: inserted Book id={}", id);
return id;
}
private void step1_getExisting(Long id) {
DEMO.info("--- Step 1: session.get() on an existing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
DEMO.info("about to call session.get(Book.class, {})", id);
Book book = session.get(Book.class, id);
DEMO.info("get() returned: {}", book);
em.getTransaction().commit();
em.close();
}
private void step2_getMissing(long missingId) {
DEMO.info("--- Step 2: session.get() on a missing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
DEMO.info("about to call session.get(Book.class, {})", missingId);
Book book = session.get(Book.class, missingId);
DEMO.info("get() returned: {} (no exception thrown)", book);
em.getTransaction().commit();
em.close();
}
private void step3_getReferenceExisting_noSelectUntilAccessed(Long id) {
DEMO.info("--- Step 3: session.getReference() on an existing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, id);
DEMO.info("getReference() returned proxy of class {} -- no SELECT above this line", proxy.getClass().getName());
DEMO.info("now calling proxy.getTitle() ...");
String title = proxy.getTitle();
DEMO.info("getTitle() returned '{}' -- the SELECT for this ran just above this line", title);
em.getTransaction().commit();
em.close();
}
private void step4_getReferenceMissing_exceptionOnlyOnAccess(long missingId) {
DEMO.info("--- Step 4: session.getReference() on a missing id ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, missingId);
DEMO.info("getReference() returned a proxy for a row that does not exist -- no exception yet: {}", proxy.getClass().getName());
try {
proxy.getTitle();
DEMO.info("no exception -- this line should be unreachable");
} catch (RuntimeException e) {
DEMO.info("accessing the proxy threw {}: {}", e.getClass().getName(), e.getMessage());
}
em.getTransaction().rollback();
em.close();
}
private void step5_getReferenceThenSessionClosed_lazyInitException(Long id) {
DEMO.info("--- Step 5: proxy accessed after its session is closed ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, id);
em.getTransaction().commit();
em.close();
DEMO.info("session closed. proxy in hand: {}", proxy.getClass().getName());
try {
proxy.getTitle();
DEMO.info("no exception -- this line should be unreachable");
} catch (RuntimeException e) {
DEMO.info("accessing the proxy after close threw {}: {}", e.getClass().getName(), e.getMessage());
}
}
private void step6_proxyVsRealIdentity(Long id) {
DEMO.info("--- Step 6: proxy identity vs a real loaded instance ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book real = session.get(Book.class, id);
// second em/session so this is a genuinely separate proxy, not the same cached instance
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Session session2 = em2.unwrap(Session.class);
Book proxy = session2.getReference(Book.class, id);
DEMO.info("real.getClass() = {}", real.getClass().getName());
DEMO.info("proxy.getClass() = {}", proxy.getClass().getName());
DEMO.info("proxy instanceof Book.class: {}", Book.class.isInstance(proxy));
DEMO.info("real.getClass() == proxy.getClass(): {}", real.getClass() == proxy.getClass());
DEMO.info("real.equals(proxy) before proxy access: {}", real.equals(proxy));
em.getTransaction().commit();
em.close();
em2.getTransaction().commit();
em2.close();
}
}

View File

@@ -0,0 +1,58 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.WidgetIdentity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4861 ("inserting objects efficiently") and
* docs/03-inserting-objects.md. Captured verbatim into docs/output/insert-identity.txt by
* {@code scripts/run.sh insert-identity}.
*
* <p>Same {@code hibernate.jdbc.batch_size} and {@code hibernate.order_inserts} settings as
* {@link InsertSequenceRunner} -- the only difference is {@link WidgetIdentity}'s
* {@code GenerationType.IDENTITY} strategy. Compare the two captured output files directly;
* the diff between them is the entire point of this pair.
*/
@Component
@Profile("insert-identity")
public class InsertIdentityRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
private final EntityManagerFactory emf;
public InsertIdentityRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
DEMO.info("--- inserting {} WidgetIdentity rows (GenerationType.IDENTITY) ---", ROW_COUNT);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetIdentity("identity-" + i));
}
em.getTransaction().commit();
em.close();
DEMO.info("entityInsertCount = {}", stats.getEntityInsertCount());
DEMO.info("prepareStatementCount = {}", stats.getPrepareStatementCount());
DEMO.info("(with IDENTITY, expect prepareStatementCount to land close to entityInsertCount --" +
" each insert has to go to the database immediately to hand back the generated key,"
+ " so there is nothing left for hibernate.jdbc.batch_size to batch)");
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.WidgetSequence;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4861 ("inserting objects efficiently") and
* docs/03-inserting-objects.md. Captured verbatim into docs/output/insert-sequence.txt by
* {@code scripts/run.sh insert-sequence}.
*
* <p>Same {@code hibernate.jdbc.batch_size} and {@code hibernate.order_inserts} settings as
* {@link InsertIdentityRunner} -- see that class's Javadoc for what this pair is demonstrating.
*/
@Component
@Profile("insert-sequence")
public class InsertSequenceRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
private final EntityManagerFactory emf;
public InsertSequenceRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
DEMO.info("--- inserting {} WidgetSequence rows (GenerationType.SEQUENCE, allocationSize=25) ---", ROW_COUNT);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetSequence("sequence-" + i));
}
em.getTransaction().commit();
em.close();
DEMO.info("entityInsertCount = {}", stats.getEntityInsertCount());
DEMO.info("prepareStatementCount = {}", stats.getPrepareStatementCount());
DEMO.info("(with SEQUENCE, the id is known before the row is written, so Hibernate can" +
" defer and batch the inserts -- expect prepareStatementCount well below entityInsertCount)");
}
}

View File

@@ -0,0 +1,113 @@
package com.ankurm.hibernatedemo.scenario;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.OptimisticLockException;
import org.hibernate.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Backs ankurm.com post 4860 ("merge() vs refresh()") and docs/02-merge-vs-refresh.md.
* Captured verbatim into docs/output/merge-vs-refresh.txt by
* {@code scripts/run.sh mergerefresh}.
*
* <p>{@link Book} carries a {@code @Version} column specifically so this scenario can show what
* {@code merge()} does when the detached instance it is given is holding a version older than
* what is currently in the database &mdash; not just what it does to an un-versioned row.
*/
@Component
@Profile("mergerefresh")
public class MergeVsRefreshRunner implements CommandLineRunner {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private final EntityManagerFactory emf;
public MergeVsRefreshRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(String... args) {
Long id = seedOneBook();
Book detached = loadThenDetach(id);
simulateAnotherProcessEditingTheRow(id);
mergeStaleDetachedInstance(detached);
refreshSilentlyDiscardsUnflushedEdit(id);
}
private Long seedOneBook() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book("Clean Code", "Robert C. Martin");
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
DEMO.info("SEED: inserted {}", book);
return id;
}
private Book loadThenDetach(Long id) {
DEMO.info("--- Step 1: load the row, then close the session (entity is now detached) ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book book = session.get(Book.class, id);
em.getTransaction().commit();
em.close();
DEMO.info("detached instance in hand: {}", book);
return book;
}
private void simulateAnotherProcessEditingTheRow(Long id) {
DEMO.info("--- Step 2: a second, independent session edits the same row and commits ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book book = session.get(Book.class, id);
book.setTitle("Clean Code (2nd Edition)");
em.getTransaction().commit();
em.close();
DEMO.info("second session committed: {} -- version column has now advanced in the database", book);
}
private void mergeStaleDetachedInstance(Book detached) {
DEMO.info("--- Step 3: mutate the ORIGINAL detached instance (still holding the OLD version) and merge() it ---");
detached.setAuthor("Robert C. Martin (Uncle Bob)");
DEMO.info("detached instance before merge (note the version and title are both stale): {}", detached);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
try {
Book merged = session.merge(detached);
em.getTransaction().commit();
DEMO.info("merge() succeeded, returned managed instance: {}", merged);
} catch (OptimisticLockException e) {
em.getTransaction().rollback();
DEMO.info("merge() threw {}: {}", e.getClass().getName(), e.getMessage());
DEMO.info("the title change from Step 2 survives untouched -- merge() refused to apply a write built on a stale version");
} finally {
em.close();
}
}
private void refreshSilentlyDiscardsUnflushedEdit(Long id) {
DEMO.info("--- Step 4: refresh() on a MANAGED entity with an unflushed local edit ---");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book book = session.get(Book.class, id);
book.setAuthor("SOMEONE ELSE ENTIRELY (never flushed)");
DEMO.info("before refresh(): {}", book);
session.refresh(book);
DEMO.info("after refresh(): {} -- the local edit is gone, no exception was thrown", book);
em.getTransaction().commit();
em.close();
}
}

View File

@@ -0,0 +1,34 @@
spring:
main:
web-application-type: none
banner-mode: off
datasource:
url: jdbc:h2:mem:hibernate-demo;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
open-in-view: false
properties:
hibernate:
show_sql: true
format_sql: false
use_sql_comments: true
generate_statistics: true
jdbc:
batch_size: 25
order_inserts: true
order_updates: true
logging:
level:
root: WARN
DEMO: INFO
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
org.hibernate.engine.jdbc.batch.internal.BatchingBatch: DEBUG
org.hibernate.stat: INFO
pattern:
console: "%msg%n"

View File

@@ -0,0 +1,93 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetAlloc1;
import com.ankurm.hibernatedemo.model.WidgetAlloc10;
import com.ankurm.hibernatedemo.model.WidgetAlloc25;
import com.ankurm.hibernatedemo.model.WidgetAlloc50;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.function.Function;
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 ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=AllocationSizeSweepTest test}.
*
* <p>Holds {@code hibernate.jdbc.batch_size=25} fixed (the application.yml default) and sweeps
* {@code allocationSize} across four otherwise-identical entities: {@link WidgetAlloc1},
* {@link WidgetAlloc10}, {@link WidgetAlloc25}, {@link WidgetAlloc50}. 30 rows each. Numbers are
* asserted, not predicted -- see the class Javadoc on each entity for why they're separate
* classes rather than one parameterized mapping.
*/
@SpringBootTest
class AllocationSizeSweepTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
private int insertRowsAndReturnPreparedStatementCount(Function<String, Object> factory) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(factory.apply("w-" + i));
}
em.getTransaction().commit();
em.close();
return (int) stats.getPrepareStatementCount();
}
@Test
void allocationSize1_everyRowNeedsItsOwnSequenceCall() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc1::new);
DEMO.info("allocationSize=1, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// Measured via `mvn test` (the full suite), reproduced twice: 31, not the 32 a naive
// "2 insert batches + 30 sequence calls" arithmetic predicts. allocationSize=1 forces a
// sequence call practically every row, which dominates the count either way -- but the
// exact figure is asserted from the real run, not derived on paper. See
// docs/03-inserting-objects.md for the honest version of this story, including where the
// paper arithmetic was wrong.
assertThat(count).isEqualTo(31);
}
@Test
void allocationSize10_threeSequenceRefills() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc10::new);
DEMO.info("allocationSize=10, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + ceil(30/10)=3 sequence calls.
assertThat(count).isEqualTo(2 + 3);
}
@Test
void allocationSize25_matchesBatchSize_twoSequenceRefills() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc25::new);
DEMO.info("allocationSize=25, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + ceil(30/25)=2 sequence calls -- this is WidgetSequence's
// configuration, confirmed again here for the sweep table.
assertThat(count).isEqualTo(2 + 2);
}
@Test
void allocationSize50_oneSequenceCallCoversAllThirtyRows() {
int count = insertRowsAndReturnPreparedStatementCount(WidgetAlloc50::new);
DEMO.info("allocationSize=50, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// 2 insert batches (25 + 5) + a single sequence call (50 >= 30, the whole run fits in
// one allocated block) = 3. Measured and reproduced via `mvn test`.
assertThat(count).isEqualTo(3);
}
}

View File

@@ -0,0 +1,120 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep1;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep10;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep25;
import com.ankurm.hibernatedemo.model.WidgetBatchSweep50;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Nested;
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 ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=BatchSizeSweepTest test}.
*
* <p>The companion sweep to {@code AllocationSizeSweepTest}: holds {@code allocationSize=50}
* fixed and sweeps {@code hibernate.jdbc.batch_size} across 1, 10, 25, 50 -- each as its own
* {@code @Nested @SpringBootTest} so each gets a genuinely separate Hibernate configuration
* rather than one mutated at runtime.
*
* <p><strong>Each sweep point uses its own dedicated entity and sequence</strong>
* ({@code WidgetBatchSweep1/10/25/50}), even though all four mappings are identical. The first
* version of this test shared a single sequence across all four nested classes and got
* unstable, run-order-dependent {@code prepareStatementCount} numbers as a result -- a real
* finding in its own right, not a hypothetical one. See docs/03-inserting-objects.md for the
* writeup; the fix is isolation, not a smarter assertion.
*/
class BatchSizeSweepTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 30;
private static int insertRowsAndReturnPreparedStatementCount(
EntityManagerFactory emf, java.util.function.Function<String, Object> factory) {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(factory.apply("w-" + i));
}
em.getTransaction().commit();
em.close();
return (int) stats.getPrepareStatementCount();
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=1")
class BatchSize1 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeOne_batchingEffectivelyDisabled() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep1::new);
DEMO.info("allocationSize=50, batch_size=1, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// batch_size=1 means no real batching: close to one prepared statement per row.
assertThat(count).isEqualTo(32);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=10")
class BatchSize10 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeTen() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep10::new);
DEMO.info("allocationSize=50, batch_size=10, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
// Once batching is enabled at all (batch_size > 1), the insert side of
// prepareStatementCount collapses to a small constant regardless of the exact
// batch_size -- see batchSizeTwentyFive and batchSizeFifty below, which measure the
// same value. batch_size clearly still governs how many rows go into each JDBC
// executeBatch() call (that's real and documented), it just isn't visible in this
// particular statistic once batching is on.
assertThat(count).isEqualTo(2);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=25")
class BatchSize25 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeTwentyFive() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep25::new);
DEMO.info("allocationSize=50, batch_size=25, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
assertThat(count).isEqualTo(2);
}
}
@Nested
@SpringBootTest(properties = "spring.jpa.properties.hibernate.jdbc.batch_size=50")
class BatchSize50 {
@Autowired
private EntityManagerFactory emf;
@Test
void batchSizeFifty() {
int count = insertRowsAndReturnPreparedStatementCount(emf, WidgetBatchSweep50::new);
DEMO.info("allocationSize=50, batch_size=50, {} rows -> prepareStatementCount={}", ROW_COUNT, count);
assertThat(count).isEqualTo(2);
}
}
}

View File

@@ -0,0 +1,246 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityNotFoundException;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.LazyInitializationException;
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 ankurm.com post 4859. Docs: docs/01-get-vs-load.md.
*
* <p>Run with {@code ./mvnw -Dtest=GetVsGetReferenceTest test}. Every assertion here was first
* observed by running the same code and reading the log, then pinned down as an assertion --
* none of the outcomes below were assumed going in.
*/
@SpringBootTest
class GetVsGetReferenceTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedBook(String title) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book(title, "Test Author");
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
// ---- Part 1: four calls, four outcomes ----
@Test
void getOnExistingId_firesSelect_returnsRealEntity() {
Long id = seedBook("Effective Java");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book book = session.get(Book.class, id);
assertThat(stats().getPrepareStatementCount()).as("get() on an existing id must fire a SELECT").isEqualTo(1);
assertThat(book).isNotNull();
assertThat(book.getClass()).isEqualTo(Book.class);
em.getTransaction().commit();
em.close();
}
@Test
void getOnMissingId_firesSelect_returnsNull() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book book = session.get(Book.class, 999_111_222L);
assertThat(stats().getPrepareStatementCount()).as("get() on a missing id still fires a SELECT").isEqualTo(1);
assertThat(book).isNull();
em.getTransaction().commit();
em.close();
}
@Test
void getReferenceOnExistingId_noSelectUntilPropertyAccessed() {
Long id = seedBook("Domain-Driven Design");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
stats().clear();
Book proxy = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("getReference() must not fire a SELECT at the call site")
.isEqualTo(0);
String title = proxy.getTitle();
assertThat(stats().getPrepareStatementCount())
.as("the SELECT is deferred until a non-id accessor is called")
.isEqualTo(1);
assertThat(title).isEqualTo("Domain-Driven Design");
em.getTransaction().commit();
em.close();
}
@Test
void getReferenceOnMissingId_noExceptionUntilAccessed() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, 999_333_444L);
// No exception yet -- constructing the proxy never touched the database.
assertThat(proxy).isNotNull();
EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, proxy::getTitle);
DEMO.info("getReference() on a missing id, once accessed, threw: {}: {}", ex.getClass().getName(), ex.getMessage());
em.getTransaction().rollback();
em.close();
}
// ---- Part 2: same-session matrix ----
// For each combination, both calls target the SAME id in the SAME session.
@Test
void sessionMatrix_getThenGet_secondCallHitsL1Cache_sameInstance() {
Long id = seedBook("Matrix: get/get");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book first = session.get(Book.class, id);
stats().clear();
Book second = session.get(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("second get() in the same session must NOT re-fire a SELECT (L1 cache hit)")
.isEqualTo(0);
assertThat(second).isSameAs(first);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getReferenceThenGetReference_secondCallHitsL1Cache_sameInstance() {
Long id = seedBook("Matrix: getReference/getReference");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book first = session.getReference(Book.class, id);
stats().clear();
Book second = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("second getReference() in the same session must not fire anything either -- still just a reference")
.isEqualTo(0);
assertThat(second).isSameAs(first);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getThenGetReference_returnsTheSameAlreadyInitializedInstance() {
Long id = seedBook("Matrix: get/getReference");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book real = session.get(Book.class, id);
stats().clear();
Book second = session.getReference(Book.class, id);
assertThat(stats().getPrepareStatementCount())
.as("getReference() after get() must not fire a SELECT -- the real entity is already in the L1 cache")
.isEqualTo(0);
assertThat(second)
.as("getReference() returns the SAME already-managed real instance, not a new proxy, once one exists in this session")
.isSameAs(real);
assertThat(second.getClass()).isEqualTo(Book.class);
em.getTransaction().commit();
em.close();
}
@Test
void sessionMatrix_getReferenceThenGet_getReturnsTheExistingProxyAndDoesNotForceInitialization() {
Long id = seedBook("Matrix: getReference/get");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book proxy = session.getReference(Book.class, id);
stats().clear();
Book second = session.get(Book.class, id);
assertThat(second)
.as("get() after getReference() returns the SAME proxy already sitting in the L1 cache")
.isSameAs(proxy);
DEMO.info("get() after getReference(): prepareStatementCount for this call = {}, returned class = {}",
stats().getPrepareStatementCount(), second.getClass().getName());
em.getTransaction().commit();
em.close();
}
// ---- Part 3: proxy identity experiment ----
@Test
void proxyIdentity_instanceofSurvives_equalsDoesNot() {
Long id = seedBook("Proxy Identity");
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Book real = em1.unwrap(Session.class).get(Book.class, id);
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Book proxy = em2.unwrap(Session.class).getReference(Book.class, id);
assertThat(proxy).isInstanceOf(Book.class);
assertThat(org.hibernate.Hibernate.getClass(proxy)).isEqualTo(Book.class);
assertThat(proxy.getClass()).isNotEqualTo(Book.class);
// Book does not override equals()/hashCode() -- this is the point of the test.
assertThat(real.equals(proxy)).isFalse();
assertThat(proxy.equals(real)).isFalse();
Set<Book> set = new HashSet<>();
set.add(real);
assertThat(set.contains(proxy))
.as("a HashSet built on default equals()/hashCode() cannot recognise the proxy and the real instance as the same row")
.isFalse();
em1.getTransaction().commit();
em1.close();
em2.getTransaction().commit();
em2.close();
}
@Test
void proxyOutlivesItsSession_throwsLazyInitializationExceptionOnAccess() {
Long id = seedBook("Outlives Session");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book proxy = em.unwrap(Session.class).getReference(Book.class, id);
em.getTransaction().commit();
em.close();
assertThrows(LazyInitializationException.class, proxy::getTitle);
}
}

View File

@@ -0,0 +1,47 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetIdentity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=IdentityBatchTest test}.
*/
@SpringBootTest
class IdentityBatchTest {
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
@Test
void identityGeneratorDisablesBatching_despiteBatchSizeBeingSet() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetIdentity("identity-" + i));
}
em.getTransaction().commit();
em.close();
assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT);
assertThat(stats.getPrepareStatementCount())
.as("with GenerationType.IDENTITY, hibernate.jdbc.batch_size has nothing to batch -- "
+ "every insert is its own round trip because the generated key is only "
+ "known after the row is written")
.isEqualTo(ROW_COUNT);
}
}

View File

@@ -0,0 +1,160 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.Book;
import com.ankurm.hibernatedemo.model.Note;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Hibernate;
import org.hibernate.Session;
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 ankurm.com post 4860. Docs: docs/02-merge-vs-refresh.md.
* Run with {@code ./mvnw -Dtest=MergeRefreshTest test}.
*
* <p>The optimistic-lock-conflict case lives in {@link OptimisticLockTest} instead, since it
* needs its own careful narration of exactly when the check fires.
*/
@SpringBootTest
class MergeRefreshTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Long seedBook(String title, String status) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Book book = new Book(title, "Test Author");
book.setStatus(status);
em.persist(book);
em.getTransaction().commit();
Long id = book.getId();
em.close();
return id;
}
@Test
void mergeOfDetachedInstance_returnsTheSameManagedInstanceAlreadyInSession() {
Long id = seedBook("Managed + Detached", "DRAFT");
// A separate, already-detached copy of the same row (simulates "the object a controller
// method was handed earlier").
EntityManager scratch = emf.createEntityManager();
scratch.getTransaction().begin();
Book detached = scratch.unwrap(Session.class).get(Book.class, id);
scratch.getTransaction().commit();
scratch.close();
detached.setAuthor("Changed On The Detached Copy");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
// This session already has ITS OWN managed instance for the same row before merge() is
// ever called.
Book managed = session.get(Book.class, id);
Book result = session.merge(detached);
assertThat(result)
.as("merge() must return the identity-equal MANAGED instance already tracked by this session, not a new object")
.isSameAs(managed);
assertThat(result).isNotSameAs(detached);
assertThat(managed.getAuthor())
.as("the pre-existing managed instance is the one that actually receives the copied state")
.isEqualTo("Changed On The Detached Copy");
em.getTransaction().commit();
em.close();
}
@Test
void mergeWithCascadeInitializesTheLazyCollectionAnyway() {
Long id;
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
Book book = new Book("Lazy Collection Book", "Test Author");
seed.persist(book);
seed.flush();
seed.persist(new Note("first note", book));
seed.getTransaction().commit();
id = book.getId();
seed.close();
// Load and detach WITHOUT ever touching book.getNotes() -- the collection proxy is never
// initialized.
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Book detached = em1.unwrap(Session.class).get(Book.class, id);
assertThat(Hibernate.isInitialized(detached.getNotes()))
.as("sanity check: the collection must still be uninitialized going into detachment")
.isFalse();
em1.getTransaction().commit();
em1.close();
detached.setAuthor("Edited While Detached");
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
Session session = em2.unwrap(Session.class);
// Going in, the expectation was "merge() doesn't need to touch a collection it was
// never asked to load." That's true ONLY when the collection has no CascadeType.MERGE.
// Book.notes DOES cascade MERGE (see its Javadoc), and the measured result is the
// opposite of the naive expectation: merge() initializes the collection anyway, because
// cascading the merge to each element requires knowing what those elements are. Removing
// cascade = CascadeType.MERGE from Book.notes and re-running this test flips the result
// back to "stays uninitialized" -- confirmed with a throwaway probe before writing this
// assertion. See docs/02-merge-vs-refresh.md.
Book merged = session.merge(detached);
assertThat(Hibernate.isInitialized(merged.getNotes()))
.as("merge() DOES initialize a LAZY collection when it cascades MERGE to it -- cascading requires traversal")
.isTrue();
assertThat(merged.getAuthor()).isEqualTo("Edited While Detached");
em2.getTransaction().commit();
em2.close();
DEMO.info("merge() initialized the LAZY notes collection because CascadeType.MERGE forces traversal of it");
}
@Test
void refreshDiscardsUnflushedEditSilently_noExceptionEver() {
Long id = seedBook("Silent Overwrite", "USER_EDIT");
// Simulate an admin process changing the row out from under the in-memory object.
EntityManager admin = emf.createEntityManager();
admin.getTransaction().begin();
Book row = admin.unwrap(Session.class).get(Book.class, id);
row.setStatus("ADMIN_EDIT");
admin.getTransaction().commit();
admin.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
Book managed = session.get(Book.class, id);
assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT");
// A local, unflushed edit -- never sent to the database.
managed.setStatus("USER_EDIT");
assertThat(managed.getStatus()).isEqualTo("USER_EDIT");
session.refresh(managed);
assertThat(managed.getStatus())
.as("refresh() replaces managed state with the database row -- it does not merge the two; the local edit is simply gone, no exception")
.isEqualTo("ADMIN_EDIT");
em.getTransaction().commit();
em.close();
}
}

View File

@@ -0,0 +1,100 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.Book;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.OptimisticLockException;
import org.hibernate.Session;
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 ankurm.com post 4860. Docs: docs/02-merge-vs-refresh.md.
* Run with {@code ./mvnw -Dtest=OptimisticLockTest test}.
*
* <p>The specific thing this test pins down: exactly WHEN the version check fails. It would be
* easy to write "merge() throws OptimisticLockException" and leave it there; what actually
* happens depends on when Hibernate performs the check relative to the {@code merge()} call, the
* flush, and the commit -- and that's worth being precise about rather than assumed.
*/
@SpringBootTest
class OptimisticLockTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void mergeOfStaleVersionedEntity_throwsOptimisticLockException_andPinsDownWhen() {
// Seed.
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
Book book = new Book("Clean Code", "Robert C. Martin");
seed.persist(book);
seed.getTransaction().commit();
Long id = book.getId();
seed.close();
// Detach at version 0.
EntityManager loadEm = emf.createEntityManager();
loadEm.getTransaction().begin();
Book detached = loadEm.unwrap(Session.class).get(Book.class, id);
loadEm.getTransaction().commit();
loadEm.close();
assertThat(detached.getVersion()).isEqualTo(0L);
// A second, independent transaction advances the row to version 1.
EntityManager writer = emf.createEntityManager();
writer.getTransaction().begin();
Book row = writer.unwrap(Session.class).get(Book.class, id);
row.setTitle("Clean Code (2nd Edition)");
writer.getTransaction().commit();
writer.close();
// Mutate the STILL version-0 detached instance and attempt to merge it.
detached.setAuthor("Robert C. Martin (Uncle Bob)");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Session session = em.unwrap(Session.class);
boolean threwDuringMergeCallItself;
OptimisticLockException caught = null;
try {
session.merge(detached);
threwDuringMergeCallItself = false;
} catch (OptimisticLockException e) {
threwDuringMergeCallItself = true;
caught = e;
}
if (!threwDuringMergeCallItself) {
// merge() itself only queued the state transfer; the version check happens at flush.
caught = org.junit.jupiter.api.Assertions.assertThrows(
OptimisticLockException.class, () -> em.getTransaction().commit());
DEMO.info("OptimisticLockException surfaced at commit()/flush time, NOT from the merge() call itself.");
} else {
DEMO.info("OptimisticLockException surfaced directly from the merge() call.");
em.getTransaction().rollback();
}
assertThat(caught).isNotNull();
DEMO.info("exception: {}: {}", caught.getClass().getName(), caught.getMessage());
em.close();
// The other transaction's title change must have survived untouched.
EntityManager verify = emf.createEntityManager();
verify.getTransaction().begin();
Book current = verify.unwrap(Session.class).get(Book.class, id);
assertThat(current.getTitle()).isEqualTo("Clean Code (2nd Edition)");
assertThat(current.getAuthor()).isEqualTo("Robert C. Martin");
verify.getTransaction().commit();
verify.close();
}
}

View File

@@ -0,0 +1,51 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.model.WidgetSequence;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs ankurm.com post 4861. Docs: docs/03-inserting-objects.md.
* Run with {@code ./mvnw -Dtest=SequenceBatchTest test}.
*
* <p>{@link WidgetSequence} sets {@code allocationSize = 25}, matching
* {@code hibernate.jdbc.batch_size} in application.yml. See {@code AllocationSizeSweepTest} for
* what happens when the two are deliberately mismatched.
*/
@SpringBootTest
class SequenceBatchTest {
private static final int ROW_COUNT = 30;
@Autowired
private EntityManagerFactory emf;
@Test
void sequenceGeneratorAllowsBatching_fourPreparedStatementsForThirtyRows() {
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
stats.clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 1; i <= ROW_COUNT; i++) {
em.persist(new WidgetSequence("sequence-" + i));
}
em.getTransaction().commit();
em.close();
assertThat(stats.getEntityInsertCount()).isEqualTo(ROW_COUNT);
assertThat(stats.getPrepareStatementCount())
.as("30 rows at batch_size=25 is 2 insert batches (25 + 5); allocationSize=25 "
+ "means the first 25 ids come from one sequence call and the remaining "
+ "5 force a second -- 2 insert batches + 2 sequence calls = 4")
.isEqualTo(4);
}
}