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:
@@ -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} — docs/01-get-vs-load.md, {@link com.ankurm.hibernatedemo.scenario.GetVsLoadRunner}</li>
|
||||
* <li>{@code merge-vs-refresh} — docs/02-merge-vs-refresh.md, {@link com.ankurm.hibernatedemo.scenario.MergeVsRefreshRunner}</li>
|
||||
* <li>{@code insert-identity} / {@code insert-sequence} — 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 — there is no web server to keep alive,
|
||||
* so {@code scripts/run.sh <profile>} 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);
|
||||
}
|
||||
}
|
||||
97
src/main/java/com/ankurm/hibernatedemo/model/Book.java
Normal file
97
src/main/java/com/ankurm/hibernatedemo/model/Book.java
Normal 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 — 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
|
||||
* "USER_EDIT" vs "ADMIN_EDIT" scenario in docs/02-merge-vs-refresh.md. The
|
||||
* {@code notes} collection is LAZY and cascades MERGE only — 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);
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/ankurm/hibernatedemo/model/Note.java
Normal file
45
src/main/java/com/ankurm/hibernatedemo/model/Note.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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} — 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;
|
||||
}
|
||||
}
|
||||
@@ -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} — 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;
|
||||
}
|
||||
}
|
||||
@@ -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} — 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 — 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 — 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();
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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 — 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();
|
||||
}
|
||||
}
|
||||
34
src/main/resources/application.yml
Normal file
34
src/main/resources/application.yml
Normal 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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
120
src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java
Normal file
120
src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
160
src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java
Normal file
160
src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
100
src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java
Normal file
100
src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user