Add Hibernate 7 batches 2-6: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API and EntityManager bootstrapping (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
2026-09-18 18:23:51 +00:00
committed by Claude
co-authored by Claude Sonnet 5
commit 98bb6b4ce4
246 changed files with 19120 additions and 0 deletions
@@ -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);
}
}
@@ -0,0 +1,53 @@
package com.ankurm.hibernatedemo.association;
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.NamedEntityGraph;
import jakarta.persistence.NamedAttributeNode;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
/**
* Plain (non-batched) author used for the N+1 / fetch-join / entity-graph comparison in
* docs/12-association-mappings.md, chapter "Counting the N+1". No {@code @BatchSize} here on
* purpose &mdash; {@link BatchAuthor} is the batched twin used for the fourth number.
*/
@Entity
@Table(name = "assoc_author")
@NamedEntityGraph(name = "AssocAuthor.books", attributeNodes = @NamedAttributeNode("books"))
public class AssocAuthor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
private List<AssocBook> books = new ArrayList<>();
protected AssocAuthor() {
}
public AssocAuthor(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public List<AssocBook> getBooks() {
return books;
}
}
@@ -0,0 +1,50 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
/** Owning side (holds the FK) of {@link AssocAuthor#getBooks()}. Docs: 12-association-mappings.md. */
@Entity
@Table(name = "assoc_book")
public class AssocBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private AssocAuthor author;
protected AssocBook() {
}
public AssocBook(String title, AssocAuthor author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public AssocAuthor getAuthor() {
return author;
}
public void setAuthor(AssocAuthor author) {
this.author = author;
}
}
@@ -0,0 +1,53 @@
package com.ankurm.hibernatedemo.association;
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.Table;
import java.util.ArrayList;
import java.util.List;
/**
* Deliberately has TWO {@code List} (bag) collections so that fetch-joining both in one JPQL
* query reproduces {@code org.hibernate.loader.MultipleBagFetchException}. Docs: 12-association-mappings.md,
* chapter "MultipleBagFetchException".
*/
@Entity
@Table(name = "bag_author_list")
public class BagAuthorList {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
private List<BagBookL> books = new ArrayList<>();
@OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
private List<BagAwardL> awards = new ArrayList<>();
protected BagAuthorList() {
}
public BagAuthorList(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public List<BagBookL> getBooks() {
return books;
}
public List<BagAwardL> getAwards() {
return awards;
}
}
@@ -0,0 +1,53 @@
package com.ankurm.hibernatedemo.association;
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.Table;
import java.util.HashSet;
import java.util.Set;
/**
* Same shape as {@link BagAuthorList} but with {@code Set} collections &mdash; fetch-joining
* both does NOT throw {@code MultipleBagFetchException} (that is Fix #1), but it does reproduce
* the cartesian-product row explosion: one SQL row per (book, award) pair per author.
*/
@Entity
@Table(name = "bag_author_set")
public class BagAuthorSet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
private Set<BagBookS> books = new HashSet<>();
@OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
private Set<BagAwardS> awards = new HashSet<>();
protected BagAuthorSet() {
}
public BagAuthorSet(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public Set<BagBookS> getBooks() {
return books;
}
public Set<BagAwardS> getAwards() {
return awards;
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "bag_award_l")
public class BagAwardL {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private BagAuthorList author;
protected BagAwardL() {
}
public BagAwardL(String name, BagAuthorList author) {
this.name = name;
this.author = author;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "bag_award_s")
public class BagAwardS {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private BagAuthorSet author;
protected BagAwardS() {
}
public BagAwardS(String name, BagAuthorSet author) {
this.name = name;
this.author = author;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "bag_book_l")
public class BagBookL {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private BagAuthorList author;
protected BagBookL() {
}
public BagBookL(String title, BagAuthorList author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "bag_book_s")
public class BagBookS {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private BagAuthorSet author;
protected BagBookS() {
}
public BagBookS(String title, BagAuthorSet author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,47 @@
package com.ankurm.hibernatedemo.association;
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.Table;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.BatchSize;
/**
* Same shape as {@link AssocAuthor} but the {@code books} collection carries
* {@code @BatchSize(size = 10)} &mdash; the fourth number in the N+1 comparison table.
*/
@Entity
@Table(name = "batch_author")
public class BatchAuthor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@BatchSize(size = 10)
@OneToMany(mappedBy = "author", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
private List<BatchBook> books = new ArrayList<>();
protected BatchAuthor() {
}
public BatchAuthor(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public List<BatchBook> getBooks() {
return books;
}
}
@@ -0,0 +1,37 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "batch_book")
public class BatchBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private BatchAuthor author;
protected BatchBook() {
}
public BatchBook(String title, BatchAuthor author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,55 @@
package com.ankurm.hibernatedemo.association;
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.Table;
import java.util.ArrayList;
import java.util.List;
/**
* {@code cascade = ALL, orphanRemoval = true}: the "developer did not expect this" cascade
* trap. Docs: 12-association-mappings.md, chapter "Cascade and orphanRemoval".
*/
@Entity
@Table(name = "cascade_author")
public class CascadeAuthor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CascadeBook> books = new ArrayList<>();
protected CascadeAuthor() {
}
public CascadeAuthor(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public List<CascadeBook> getBooks() {
return books;
}
/**
* The bug: a developer "resets" the list by assigning a brand new collection instead of
* mutating the existing one (a common pattern when mapping from a DTO). With
* {@code orphanRemoval = true}, Hibernate sees every previously-owned book missing from the
* new collection and deletes all of them on flush.
*/
public void replaceBooksWithNewList(List<CascadeBook> newBooks) {
this.books = new ArrayList<>(newBooks);
}
}
@@ -0,0 +1,41 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "cascade_book")
public class CascadeBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private CascadeAuthor author;
protected CascadeBook() {
}
public CascadeBook(String title, CascadeAuthor author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
/** Owning side (holds {@code user_id} FK) of {@link LazyUser#getProfile()}. */
@Entity
@Table(name = "lazy_profile")
public class LazyProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private LazyUser user;
protected LazyProfile() {
}
public LazyProfile(String bio, LazyUser user) {
this.bio = bio;
this.user = user;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,50 @@
package com.ankurm.hibernatedemo.association;
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.OneToOne;
import jakarta.persistence.Table;
/**
* Non-owning ("mappedBy") side of an optional {@code @OneToOne}. Docs: 12-association-mappings.md,
* chapter "The @OneToOne lazy trap". Even though {@link #profile} is declared
* {@code FetchType.LAZY}, Hibernate cannot build a proxy for it here without bytecode
* enhancement: it does not hold the foreign key, so it cannot know whether a profile row
* exists without querying. The result is an eager extra SELECT on every load of LazyUser.
*/
@Entity
@Table(name = "lazy_user")
public class LazyUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL, optional = true)
private LazyProfile profile;
protected LazyUser() {
}
public LazyUser(String username) {
this.username = username;
}
public Long getId() {
return id;
}
public LazyProfile getProfile() {
return profile;
}
public void setProfile(LazyProfile profile) {
this.profile = profile;
}
}
@@ -0,0 +1,36 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
/** Owning side using {@code @MapsId}: its {@code @Id} IS the {@code user_id} FK value. */
@Entity
@Table(name = "mi_profile")
public class MiProfile {
@Id
private Long id;
private String bio;
@MapsId
@OneToOne
@JoinColumn(name = "id")
private MiUser user;
protected MiProfile() {
}
public MiProfile(String bio, MiUser user) {
this.bio = bio;
this.user = user;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* The @MapsId fix: this entity carries NO inverse {@code @OneToOne} field at all. A profile is
* looked up on demand with {@code session.find(MiProfile.class, userId)} because it shares the
* same primary key value as the user (see {@link MiProfile}), so there is nothing to proxy and
* nothing forces an eager join when loading a user.
*/
@Entity
@Table(name = "mi_user")
public class MiUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
protected MiUser() {
}
public MiUser(String username) {
this.username = username;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,47 @@
package com.ankurm.hibernatedemo.association;
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.Table;
import java.util.ArrayList;
import java.util.List;
/**
* {@code cascade = {PERSIST, MERGE}}, no {@code orphanRemoval}. Used for two things:
* (1) removing a child from the in-memory collection and flushing does nothing to the row
* (docs: "orphanRemoval off"), and (2) mutating only this inverse side (the collection) without
* touching {@link NoOrphanBook#setAuthor} never writes the FK (docs: "owning side").
*/
@Entity
@Table(name = "no_orphan_author")
public class NoOrphanAuthor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
private List<NoOrphanBook> books = new ArrayList<>();
protected NoOrphanAuthor() {
}
public NoOrphanAuthor(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public List<NoOrphanBook> getBooks() {
return books;
}
}
@@ -0,0 +1,45 @@
package com.ankurm.hibernatedemo.association;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "no_orphan_book")
public class NoOrphanBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private NoOrphanAuthor author;
protected NoOrphanBook() {
}
public NoOrphanBook(String title, NoOrphanAuthor author) {
this.title = title;
this.author = author;
}
public Long getId() {
return id;
}
public NoOrphanAuthor getAuthor() {
return author;
}
public void setAuthor(NoOrphanAuthor author) {
this.author = author;
}
}
@@ -0,0 +1,54 @@
package com.ankurm.hibernatedemo.bootstrap;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
/**
* Backs ankurm.com post 4855 (bootstrapping EntityManager). Docs: docs/17-entitymanager-bootstrap.md.
*
* <p>Deliberately NOT scanned by Spring's own auto-configured {@code EntityManagerFactory}
* (see {@code META-INF/persistence.xml} under {@code src/test/resources}, which is what
* {@link EntityManagerBootstrapTest} actually bootstraps against) -- this chapter is about raw
* JPA bootstrapping, deliberately bypassing Spring Boot's autoconfiguration entirely so the two
* paths the fictional original article described (XML vs {@code PersistenceConfiguration}) are
* both exercised for real.
*/
@Entity
public class BootstrapUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
protected BootstrapUser() {
// JPA
}
public BootstrapUser(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
@Override
public String toString() {
return "BootstrapUser{id=%s, name=%s, email=%s}".formatted(id, name, email);
}
}
@@ -0,0 +1,58 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDateTime;
/** Docs: 13-date-and-time-mapping.md, chapter "Second-precision / truncation". */
@Entity
@Table(name = "nano_precision")
public class NanoPrecisionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime plainLocalDateTime;
@Column(precision = 9)
private LocalDateTime highPrecisionLocalDateTime;
private Instant plainInstant;
protected NanoPrecisionEntity() {
}
public Long getId() {
return id;
}
public LocalDateTime getPlainLocalDateTime() {
return plainLocalDateTime;
}
public void setPlainLocalDateTime(LocalDateTime v) {
this.plainLocalDateTime = v;
}
public LocalDateTime getHighPrecisionLocalDateTime() {
return highPrecisionLocalDateTime;
}
public void setHighPrecisionLocalDateTime(LocalDateTime v) {
this.highPrecisionLocalDateTime = v;
}
public Instant getPlainInstant() {
return plainInstant;
}
public void setPlainInstant(Instant v) {
this.plainInstant = v;
}
}
@@ -0,0 +1,42 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import java.time.Instant;
/**
* Deliberately misuses the deprecated {@code @Temporal} annotation on a {@code java.time.Instant}
* field -- not portable per the Jakarta Persistence 3.2 spec, but does Hibernate 7.4.5 actually
* reject it? Docs: 13-date-and-time-mapping.md, chapter "@Temporal verified".
*/
@Entity
@Table(name = "temporal_on_java_time")
public class TemporalOnJavaTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Temporal(TemporalType.TIMESTAMP)
private Instant instantWithTemporalAnnotation;
protected TemporalOnJavaTimeEntity() {
}
public Long getId() {
return id;
}
public Instant getInstantWithTemporalAnnotation() {
return instantWithTemporalAnnotation;
}
public void setInstantWithTemporalAnnotation(Instant v) {
this.instantWithTemporalAnnotation = v;
}
}
@@ -0,0 +1,137 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
/**
* Every basic temporal type in one entity so a single {@code show-create-table} run captures
* the DDL Hibernate 7.4.5 generates for each. Docs: 13-date-and-time-mapping.md, chapter "Basic temporal
* types round trip". {@code legacyDateNoTemporal} deliberately has NO {@code @Temporal}
* annotation to show what Hibernate does by default for {@code java.util.Date}.
*/
@Entity
@Table(name = "temporal_types")
public class TemporalTypesEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDate localDate;
private LocalDateTime localDateTime;
private LocalTime localTime;
private Instant instant;
private OffsetDateTime offsetDateTime;
private ZonedDateTime zonedDateTime;
@Temporal(TemporalType.DATE)
private Date legacyDateAsDate;
@Temporal(TemporalType.TIMESTAMP)
private Date legacyDateAsTimestamp;
private Date legacyDateNoTemporal;
@Temporal(TemporalType.TIMESTAMP)
private Calendar legacyCalendar;
protected TemporalTypesEntity() {
}
public Long getId() {
return id;
}
public LocalDate getLocalDate() {
return localDate;
}
public void setLocalDate(LocalDate localDate) {
this.localDate = localDate;
}
public LocalDateTime getLocalDateTime() {
return localDateTime;
}
public void setLocalDateTime(LocalDateTime localDateTime) {
this.localDateTime = localDateTime;
}
public LocalTime getLocalTime() {
return localTime;
}
public void setLocalTime(LocalTime localTime) {
this.localTime = localTime;
}
public Instant getInstant() {
return instant;
}
public void setInstant(Instant instant) {
this.instant = instant;
}
public OffsetDateTime getOffsetDateTime() {
return offsetDateTime;
}
public void setOffsetDateTime(OffsetDateTime offsetDateTime) {
this.offsetDateTime = offsetDateTime;
}
public ZonedDateTime getZonedDateTime() {
return zonedDateTime;
}
public void setZonedDateTime(ZonedDateTime zonedDateTime) {
this.zonedDateTime = zonedDateTime;
}
public Date getLegacyDateAsDate() {
return legacyDateAsDate;
}
public void setLegacyDateAsDate(Date legacyDateAsDate) {
this.legacyDateAsDate = legacyDateAsDate;
}
public Date getLegacyDateAsTimestamp() {
return legacyDateAsTimestamp;
}
public void setLegacyDateAsTimestamp(Date legacyDateAsTimestamp) {
this.legacyDateAsTimestamp = legacyDateAsTimestamp;
}
public Date getLegacyDateNoTemporal() {
return legacyDateNoTemporal;
}
public void setLegacyDateNoTemporal(Date legacyDateNoTemporal) {
this.legacyDateNoTemporal = legacyDateNoTemporal;
}
public Calendar getLegacyCalendar() {
return legacyCalendar;
}
public void setLegacyCalendar(Calendar legacyCalendar) {
this.legacyCalendar = legacyCalendar;
}
}
@@ -0,0 +1,106 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import org.hibernate.annotations.TimeZoneStorage;
import org.hibernate.annotations.TimeZoneStorageType;
/**
* One {@code OffsetDateTime} column per {@code @TimeZoneStorage} mode plus one with NO
* annotation at all (to observe Hibernate 7.4.5's actual default). Docs: 13-date-and-time-mapping.md,
* chapter "The central experiment". Run alongside {@code javap
* org.hibernate.annotations.TimeZoneStorageType} to confirm the enum constants independently
* of this entity.
*/
@Entity
@Table(name = "tz_storage")
public class TimeZoneStorageEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// No @TimeZoneStorage annotation at all -- whatever Hibernate 7.4.5 defaults to.
@Column(name = "no_annotation_col")
private OffsetDateTime noAnnotation;
@TimeZoneStorage(TimeZoneStorageType.NATIVE)
@Column(name = "native_col")
private OffsetDateTime nativeMode;
@TimeZoneStorage(TimeZoneStorageType.NORMALIZE)
@Column(name = "normalize_col")
private OffsetDateTime normalizeMode;
@TimeZoneStorage(TimeZoneStorageType.NORMALIZE_UTC)
@Column(name = "normalize_utc_col")
private OffsetDateTime normalizeUtcMode;
@TimeZoneStorage(TimeZoneStorageType.COLUMN)
@Column(name = "column_mode_col")
private OffsetDateTime columnMode;
@TimeZoneStorage(TimeZoneStorageType.AUTO)
@Column(name = "auto_col")
private OffsetDateTime autoMode;
protected TimeZoneStorageEntity() {
}
public Long getId() {
return id;
}
public OffsetDateTime getNoAnnotation() {
return noAnnotation;
}
public void setNoAnnotation(OffsetDateTime v) {
this.noAnnotation = v;
}
public OffsetDateTime getNativeMode() {
return nativeMode;
}
public void setNativeMode(OffsetDateTime v) {
this.nativeMode = v;
}
public OffsetDateTime getNormalizeMode() {
return normalizeMode;
}
public void setNormalizeMode(OffsetDateTime v) {
this.normalizeMode = v;
}
public OffsetDateTime getNormalizeUtcMode() {
return normalizeUtcMode;
}
public void setNormalizeUtcMode(OffsetDateTime v) {
this.normalizeUtcMode = v;
}
public OffsetDateTime getColumnMode() {
return columnMode;
}
public void setColumnMode(OffsetDateTime v) {
this.columnMode = v;
}
public OffsetDateTime getAutoMode() {
return autoMode;
}
public void setAutoMode(OffsetDateTime v) {
this.autoMode = v;
}
}
@@ -0,0 +1,44 @@
package com.ankurm.hibernatedemo.immutable;
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 row for {@link RateWithAuditTrail}'s @Immutable collection. */
@Entity
public class AuditTrail {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "audit_seq")
private Long id;
private String note;
@ManyToOne
@JoinColumn(name = "rate_with_audit_id")
private RateWithAuditTrail rateWithAuditTrail;
protected AuditTrail() {
// JPA
}
public AuditTrail(String note) {
this.note = note;
}
public Long getId() {
return id;
}
public String getNote() {
return note;
}
@Override
public String toString() {
return "AuditTrail{id=%s, note=%s}".formatted(id, note);
}
}
@@ -0,0 +1,58 @@
package com.ankurm.hibernatedemo.immutable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import org.hibernate.annotations.Immutable;
/**
* The headline case for docs/07-immutable-entities.md: an {@code @Immutable} entity with a mutable
* Java setter. Nothing stops the field mutation in the JVM -- the point is what happens (or
* doesn't) when the mutated instance is flushed.
*/
@Entity
@Immutable
public class ExchangeRate {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rate_seq")
private Long id;
private String pair;
@jakarta.persistence.Column(precision = 19, scale = 4)
private BigDecimal rate;
protected ExchangeRate() {
// JPA
}
public ExchangeRate(String pair, BigDecimal rate) {
this.pair = pair;
this.rate = rate;
}
public Long getId() {
return id;
}
public String getPair() {
return pair;
}
public BigDecimal getRate() {
return rate;
}
/** Ordinary setter -- @Immutable is a Hibernate-engine concept, not a Java one. */
public void setRate(BigDecimal rate) {
this.rate = rate;
}
@Override
public String toString() {
return "ExchangeRate{id=%s, pair=%s, rate=%s}".formatted(id, pair, rate);
}
}
@@ -0,0 +1,56 @@
package com.ankurm.hibernatedemo.immutable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import org.hibernate.annotations.Immutable;
/**
* Same shape as {@link ExchangeRate} but carries a {@code @Version} column, to check whether
* Hibernate 7.4.5 rejects the {@code @Immutable} + {@code @Version} combination outright, and
* whether the version column ever increments if it doesn't.
*/
@Entity
@Immutable
public class ExchangeRateVersioned {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rate_v_seq")
private Long id;
private String pair;
@jakarta.persistence.Column(precision = 19, scale = 4)
private BigDecimal rate;
@Version
private Long version;
protected ExchangeRateVersioned() {
// JPA
}
public ExchangeRateVersioned(String pair, BigDecimal rate) {
this.pair = pair;
this.rate = rate;
}
public Long getId() {
return id;
}
public BigDecimal getRate() {
return rate;
}
public void setRate(BigDecimal rate) {
this.rate = rate;
}
public Long getVersion() {
return version;
}
}
@@ -0,0 +1,47 @@
package com.ankurm.hibernatedemo.immutable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;
/**
* A perfectly ordinary, non-{@code @Immutable} entity, used as the control group when
* comparing {@code @Immutable} against {@code Session.setDefaultReadOnly(true)} and
* {@code Session.setReadOnly(entity, true)} -- those are Session/query-scoped read-only knobs
* that apply to entities that were never annotated at all.
*/
@Entity
public class PlainRate {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "plain_rate_seq")
private Long id;
private String pair;
@jakarta.persistence.Column(precision = 19, scale = 4)
private BigDecimal rate;
protected PlainRate() {
// JPA
}
public PlainRate(String pair, BigDecimal rate) {
this.pair = pair;
this.rate = rate;
}
public Long getId() {
return id;
}
public BigDecimal getRate() {
return rate;
}
public void setRate(BigDecimal rate) {
this.rate = rate;
}
}
@@ -0,0 +1,51 @@
package com.ankurm.hibernatedemo.immutable;
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 java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.Immutable;
/**
* A MUTABLE parent entity (no {@code @Immutable} on the class) whose collection is marked
* {@code @Immutable}. This isolates the collection-level annotation's own behaviour, per the
* article's "Advanced Usage: Immutable Collections" section, from the entity-level one.
*/
@Entity
public class RateWithAuditTrail {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rate_audit_seq")
private Long id;
private String pair;
@Immutable
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<AuditTrail> auditTrails = new ArrayList<>();
protected RateWithAuditTrail() {
// JPA
}
public RateWithAuditTrail(String pair) {
this.pair = pair;
}
public Long getId() {
return id;
}
public String getPair() {
return pair;
}
public List<AuditTrail> getAuditTrails() {
return auditTrails;
}
}
@@ -0,0 +1,90 @@
package com.ankurm.hibernatedemo.immutable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.hibernate.annotations.Immutable;
/**
* Same 12-column shape as {@link WideMutableRow}, but @Immutable, for the flush-cost
* comparison in docs/07-immutable-entities.md.
*/
@Entity
@Immutable
public class WideImmutableRow {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "wide_immutable_seq")
private Long id;
private String f1;
private String f2;
private String f3;
private String f4;
private String f5;
private String f6;
private String f7;
private String f8;
private String f9;
private String f10;
private String f11;
private String f12;
protected WideImmutableRow() {
// JPA
}
public WideImmutableRow(String seed) {
this.f1 = seed;
this.f2 = seed;
this.f3 = seed;
this.f4 = seed;
this.f5 = seed;
this.f6 = seed;
this.f7 = seed;
this.f8 = seed;
this.f9 = seed;
this.f10 = seed;
this.f11 = seed;
this.f12 = seed;
}
public Long getId() { return id; }
public String getF1() { return f1; }
public void setF1(String v) { this.f1 = v; }
public String getF2() { return f2; }
public void setF2(String v) { this.f2 = v; }
public String getF3() { return f3; }
public void setF3(String v) { this.f3 = v; }
public String getF4() { return f4; }
public void setF4(String v) { this.f4 = v; }
public String getF5() { return f5; }
public void setF5(String v) { this.f5 = v; }
public String getF6() { return f6; }
public void setF6(String v) { this.f6 = v; }
public String getF7() { return f7; }
public void setF7(String v) { this.f7 = v; }
public String getF8() { return f8; }
public void setF8(String v) { this.f8 = v; }
public String getF9() { return f9; }
public void setF9(String v) { this.f9 = v; }
public String getF10() { return f10; }
public void setF10(String v) { this.f10 = v; }
public String getF11() { return f11; }
public void setF11(String v) { this.f11 = v; }
public String getF12() { return f12; }
public void setF12(String v) { this.f12 = v; }
}
@@ -0,0 +1,90 @@
package com.ankurm.hibernatedemo.immutable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
/**
* A wide (12-column) MUTABLE entity, used only to give per-entity dirty checking something
* non-trivial to compare on a full flush -- with 1-2 fields the per-field snapshot comparison
* cost is too small to distinguish from measurement noise. See docs/07-immutable-entities.md,
* "@Version and dirty-check cost".
*/
@Entity
public class WideMutableRow {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "wide_mutable_seq")
private Long id;
private String f1;
private String f2;
private String f3;
private String f4;
private String f5;
private String f6;
private String f7;
private String f8;
private String f9;
private String f10;
private String f11;
private String f12;
protected WideMutableRow() {
// JPA
}
public WideMutableRow(String seed) {
this.f1 = seed;
this.f2 = seed;
this.f3 = seed;
this.f4 = seed;
this.f5 = seed;
this.f6 = seed;
this.f7 = seed;
this.f8 = seed;
this.f9 = seed;
this.f10 = seed;
this.f11 = seed;
this.f12 = seed;
}
public Long getId() { return id; }
public String getF1() { return f1; }
public void setF1(String v) { this.f1 = v; }
public String getF2() { return f2; }
public void setF2(String v) { this.f2 = v; }
public String getF3() { return f3; }
public void setF3(String v) { this.f3 = v; }
public String getF4() { return f4; }
public void setF4(String v) { this.f4 = v; }
public String getF5() { return f5; }
public void setF5(String v) { this.f5 = v; }
public String getF6() { return f6; }
public void setF6(String v) { this.f6 = v; }
public String getF7() { return f7; }
public void setF7(String v) { this.f7 = v; }
public String getF8() { return f8; }
public void setF8(String v) { this.f8 = v; }
public String getF9() { return f9; }
public void setF9(String v) { this.f9 = v; }
public String getF10() { return f10; }
public void setF10(String v) { this.f10 = v; }
public String getF11() { return f11; }
public void setF11(String v) { this.f11 = v; }
public String getF12() { return f12; }
public void setF12(String v) { this.f12 = v; }
}
@@ -0,0 +1,46 @@
package com.ankurm.hibernatedemo.mappingstyle;
/**
* Deliberately annotation-free POJO, mapped only through
* {@code HbmEmployee.hbm.xml} (legacy Hibernate mapping format).
*
* <p>Backs the empirical hbm.xml probe in {@code HbmXmlBootTest}. Docs: docs/04-annotations-vs-xml.md.
*/
public class HbmEmployee {
private Long id;
private String firstName;
private String email;
public HbmEmployee() {
}
public HbmEmployee(String firstName, String email) {
this.firstName = firstName;
this.email = email;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
@@ -0,0 +1,39 @@
package com.ankurm.hibernatedemo.mappingstyle;
/**
* Zero annotations. Mapped entirely by {@code mapping-xml-natural-id.xml}, using Hibernate's
* native "mapping.xml" XML dialect (namespace {@code http://www.hibernate.org/xsd/orm/mapping},
* schema {@code mapping-7.0.xsd}) -- NOT the JPA-standard orm.xml dialect, which has no
* &lt;natural-id&gt; element at all. Proves a Hibernate-only concept (@NaturalId) can be expressed
* in XML, just not in the portable JPA orm.xml XSD.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1 ("what XML can do that annotations cannot" --
* inverted: what one XML dialect can do that the other XML dialect and annotations both cannot
* express the same way).
*/
public class MappingXmlNaturalIdEntity {
private Long id;
private String sku;
private String name;
public MappingXmlNaturalIdEntity() {
}
public MappingXmlNaturalIdEntity(String sku, String name) {
this.sku = sku;
this.name = name;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,38 @@
package com.ankurm.hibernatedemo.mappingstyle;
/**
* Deliberately carries NO JPA/Hibernate annotations at all -- not even {@code @Entity}.
* Its only mapping is {@code orm-xml-only-mapping.xml}, registered via
* {@code spring.jpa.mapping-resources}. If a query against {@code xml_only_widgets} succeeds,
* orm.xml alone is enough to make this a managed entity.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1.
*/
public class OrmXmlOnlyEntity {
private Long id;
private String label;
public OrmXmlOnlyEntity() {
}
public OrmXmlOnlyEntity(String label) {
this.label = label;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
}
@@ -0,0 +1,39 @@
package com.ankurm.hibernatedemo.mappingstyle;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
/**
* Annotated with {@code @Column(name = "annotation_name")}. A matching orm.xml entry
* (see {@code orm-xml-override-mapping.xml}) maps the SAME field to {@code xml_name} instead.
* Whichever name shows up in the generated DDL/SQL is the winner.
*
* <p>Docs: docs/04-annotations-vs-xml.md, Topic 1 (merge/override semantics).
*/
@Entity
public class OverrideEntity {
@Id
@GeneratedValue
private Long id;
@Column(name = "annotation_name")
private String value;
public OverrideEntity() {
}
public OverrideEntity(String value) {
this.value = value;
}
public Long getId() {
return id;
}
public String getValue() {
return value;
}
}
+97
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);
}
}
+45
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;
}
}
@@ -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} &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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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 &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;
}
}
@@ -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,25 @@
package com.ankurm.hibernatedemo.namedquery;
/** JPQL/native constructor-result projection target. Plain class version. */
public class EmployeeDto {
private final Long id;
private final String firstName;
public EmployeeDto(Long id, String firstName) {
this.id = id;
this.firstName = firstName;
}
public Long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
@Override
public String toString() {
return "EmployeeDto{id=%s, firstName=%s}".formatted(id, firstName);
}
}
@@ -0,0 +1,8 @@
package com.ankurm.hibernatedemo.namedquery;
/**
* Jakarta Persistence 3.2 alternative to a hand-written DTO class: does a Java {@code record}
* work directly as a JPQL constructor-expression result? Tested in NamedQueryExecutionTest.
*/
public record EmployeeRecordDto(Long id, String firstName) {
}
@@ -0,0 +1,38 @@
package com.ankurm.hibernatedemo.namedquery;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.NamedQuery;
/**
* Uses {@code org.hibernate.annotations.NamedQuery} (the Hibernate extension, not the JPA
* standard one) specifically to exercise an extra it offers that JPA's does not:
* {@code cacheable = true}. Docs: 14-named-queries.md, chapter "jakarta vs hibernate NamedQuery".
*/
@Entity
@Table(name = "hib_extra_employee")
@NamedQuery(name = "HibernateExtraEmployee.cacheableFindAll",
query = "SELECT e FROM HibernateExtraEmployee e",
cacheable = true)
public class HibernateExtraEmployee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected HibernateExtraEmployee() {
}
public HibernateExtraEmployee(String name) {
this.name = name;
}
public Long getId() {
return id;
}
}
@@ -0,0 +1,72 @@
package com.ankurm.hibernatedemo.namedquery;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.NamedNativeQueries;
import jakarta.persistence.NamedNativeQuery;
import jakarta.persistence.SqlResultSetMapping;
import jakarta.persistence.ConstructorResult;
import jakarta.persistence.ColumnResult;
import jakarta.persistence.Table;
/**
* Backs ankurm.com post 4877. Docs: 14-named-queries.md.
*
* <p>{@code Employee.findByName} is a valid JPA {@code @NamedQuery}, {@code Employee.byNativeDto}
* is a {@code @NamedNativeQuery} + {@code @SqlResultSetMapping} into {@link EmployeeDto} via
* {@code @ConstructorResult}.
*/
@Entity
@Table(name = "nq_employee")
@NamedQueries({
@NamedQuery(name = "Employee.findByName", query = "SELECT e FROM NqEmployee e WHERE e.firstName = :name"),
@NamedQuery(name = "Employee.findAllActive", query = "SELECT e FROM NqEmployee e WHERE e.status = 'ACTIVE' ORDER BY e.id DESC")
})
@NamedNativeQueries({
@NamedNativeQuery(
name = "Employee.byNativeDto",
query = "SELECT id, first_name AS firstName FROM nq_employee WHERE status = :status",
resultSetMapping = "EmployeeDtoMapping")
})
@SqlResultSetMapping(
name = "EmployeeDtoMapping",
classes = @ConstructorResult(
targetClass = EmployeeDto.class,
columns = {
@ColumnResult(name = "id", type = Long.class),
@ColumnResult(name = "firstName", type = String.class)
}))
public class NqEmployee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String status;
protected NqEmployee() {
}
public NqEmployee(String firstName, String status) {
this.firstName = firstName;
this.status = status;
}
public Long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public String getStatus() {
return status;
}
}
@@ -0,0 +1,46 @@
package com.ankurm.hibernatedemo.namedquery;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
/**
* Has one {@code @NamedQuery} defined via annotation ({@code XmlQueryEmployee.findBySalaryAbove})
* AND one defined via {@code orm.xml} ({@code XmlQueryEmployee.findBySalaryAboveXml}), plus an
* XML-defined query that OVERRIDES a same-named annotated one
* ({@code XmlQueryEmployee.overridden}) -- see src/main/resources/META-INF/orm.xml. Docs:
* 14-named-queries.md, chapter "Named queries in orm.xml".
*/
@Entity
@Table(name = "xml_query_employee")
@NamedQuery(name = "XmlQueryEmployee.findBySalaryAbove", query = "SELECT e FROM XmlQueryEmployee e WHERE e.salary > :min")
@NamedQuery(name = "XmlQueryEmployee.overridden", query = "SELECT e FROM XmlQueryEmployee e WHERE e.salary < 0") // deliberately wrong; orm.xml should win
public class XmlQueryEmployee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double salary;
protected XmlQueryEmployee() {
}
public XmlQueryEmployee(String name, double salary) {
this.name = name;
this.salary = salary;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,54 @@
package com.ankurm.hibernatedemo.naturalid;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import org.hibernate.annotations.NaturalId;
import org.hibernate.annotations.NaturalIdCache;
/**
* Same shape as {@link NaturalIdProduct}, but with {@code @Cacheable} + {@code @Cache} (entity
* L2 cache) AND {@code @NaturalIdCache} (the SEPARATE natural-id-to-PK resolution L2 cache
* region -- these are two different cache regions, not one).
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@Entity
@jakarta.persistence.Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@NaturalIdCache
public class CachedNaturalIdProduct {
@Id
@GeneratedValue
private Long id;
@NaturalId
@Column(nullable = false, unique = true, updatable = false)
private String sku;
private String name;
public CachedNaturalIdProduct() {
}
public CachedNaturalIdProduct(String sku, String name) {
this.sku = sku;
this.name = name;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,35 @@
package com.ankurm.hibernatedemo.naturalid;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
/**
* Parent side of the composite natural id demo -- see {@link Department}.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@Entity
public class Company {
@Id
@GeneratedValue
private Long id;
private String name;
public Company() {
}
public Company(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,58 @@
package com.ankurm.hibernatedemo.naturalid;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import org.hibernate.annotations.NaturalId;
/**
* A COMPOSITE natural id: (company, deptCode) together must be unique, not either field alone.
* Matches the article's example -- verified to actually load via
* {@code session.byNaturalId(Department.class).using(...).using(...).load()}.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@Entity
public class Department {
@Id
@GeneratedValue
private Long id;
@NaturalId
@ManyToOne
@JoinColumn(name = "company_id")
private Company company;
@NaturalId
private String deptCode;
private String name;
public Department() {
}
public Department(Company company, String deptCode, String name) {
this.company = company;
this.deptCode = deptCode;
this.name = name;
}
public Long getId() {
return id;
}
public Company getCompany() {
return company;
}
public String getDeptCode() {
return deptCode;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,44 @@
package com.ankurm.hibernatedemo.naturalid;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.hibernate.annotations.NaturalId;
/**
* {@code @NaturalId} with NO {@code mutable} attribute -- defaults to {@code mutable = false}
* (immutable). Deliberately does NOT add {@code @Column(updatable = false)}, so if Hibernate
* lets an UPDATE through at the SQL level, that is Hibernate's own natural-id immutability
* enforcement failing to stop it, not a JPA column-level guard doing the stopping.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@Entity
public class ImmutableNaturalIdEntity {
@Id
@GeneratedValue
private Long id;
@NaturalId // mutable defaults to false
private String code;
public ImmutableNaturalIdEntity() {
}
public ImmutableNaturalIdEntity(String code) {
this.code = code;
}
public Long getId() {
return id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}
@@ -0,0 +1,42 @@
package com.ankurm.hibernatedemo.naturalid;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.hibernate.annotations.NaturalId;
/**
* {@code @NaturalId(mutable = true)} -- the explicit opt-in for a natural id that IS allowed
* to change (e.g. an email address).
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@Entity
public class MutableNaturalIdEntity {
@Id
@GeneratedValue
private Long id;
@NaturalId(mutable = true)
private String code;
public MutableNaturalIdEntity() {
}
public MutableNaturalIdEntity(String code) {
this.code = code;
}
public Long getId() {
return id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}
@@ -0,0 +1,62 @@
package com.ankurm.hibernatedemo.naturalid;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.util.Objects;
import org.hibernate.Hibernate;
import org.hibernate.annotations.NaturalId;
/**
* equals()/hashCode() based on the IMMUTABLE natural id (sku), assigned in the constructor --
* exactly the pattern posts 4864/4865 recommend. Since sku is set before the object ever enters
* a Set/Map (unlike a surrogate id, which is null until flush), the hash code should be STABLE
* across persist(). This entity exists to test whether that advice actually holds up.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@Entity
public class NaturalIdEqualsEntity {
@Id
@GeneratedValue
private Long id;
@NaturalId
@Column(nullable = false, unique = true, updatable = false)
private final String sku;
private String name;
protected NaturalIdEqualsEntity() {
this.sku = null; // JPA no-arg constructor requirement
}
public NaturalIdEqualsEntity(String sku, String name) {
this.sku = sku;
this.name = name;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
if (Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
NaturalIdEqualsEntity that = (NaturalIdEqualsEntity) o;
return Objects.equals(sku, that.sku);
}
@Override
public int hashCode() {
return Objects.hash(sku);
}
}
@@ -0,0 +1,52 @@
package com.ankurm.hibernatedemo.naturalid;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.hibernate.annotations.NaturalId;
/**
* A single-field, IMMUTABLE natural id (the JPA/Hibernate default for {@code @NaturalId}).
* No {@code @Cache}/{@code @NaturalIdCache} here -- this is the baseline entity for the
* "does bySimpleNaturalId save a query without L2 cache" experiment.
*
* <p>Docs: docs/06-natural-ids.md, Topic 3.
*/
@Entity
public class NaturalIdProduct {
@Id
@GeneratedValue
private Long id;
@NaturalId
@Column(nullable = false, unique = true, updatable = false)
private String sku;
private String name;
public NaturalIdProduct() {
}
public NaturalIdProduct(String sku, String name) {
this.sku = sku;
this.name = name;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@@ -0,0 +1,49 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* {@code status} has NO {@code @Enumerated} at all -- JPA's default is ORDINAL. This entity
* exists purely to demonstrate what breaks: reordering (or inserting into the middle of) the
* enum silently repoints every stored ordinal at the wrong constant.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
@Table(name = "enum_default_ordinal_entity")
public class EnumDefaultOrdinalEntity {
@Id
@GeneratedValue
private Long id;
// No @Enumerated -- JPA default is ORDINAL.
private OrderStatus status;
public EnumDefaultOrdinalEntity() {
}
public EnumDefaultOrdinalEntity(OrderStatus status) {
this.status = status;
}
public Long getId() {
return id;
}
public OrderStatus getStatus() {
return status;
}
public void setStatus(OrderStatus status) {
this.status = status;
}
/** V1 order of constants: NEW=0, SHIPPED=1, DELIVERED=2. */
public enum OrderStatus {
NEW, SHIPPED, DELIVERED
}
}
@@ -0,0 +1,40 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* Maps the SAME table ({@code enum_default_ordinal_entity}) as {@link EnumDefaultOrdinalEntity},
* but with a status enum that has an EXTRA constant inserted before {@code SHIPPED}. This
* simulates "someone added a constant to the middle of the enum" without a migration --
* the classic ORDINAL trap. No @Enumerated here either (default ORDINAL), matching the
* original mapping exactly.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
@Table(name = "enum_default_ordinal_entity")
public class EnumReorderedV2Entity {
@Id
private Long id;
@Column(name = "status")
private ReorderedStatus status;
public Long getId() {
return id;
}
public ReorderedStatus getStatus() {
return status;
}
/** V2: a constant (PENDING_REVIEW) was inserted BEFORE SHIPPED -- ordinal 1 now means something else. */
public enum ReorderedStatus {
NEW, PENDING_REVIEW, SHIPPED, DELIVERED
}
}
@@ -0,0 +1,61 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.EnumType;
import jakarta.persistence.EnumeratedValue;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
/**
* {@code @EnumeratedValue} is NEW in Jakarta Persistence 3.2 (confirmed present via javap on
* jakarta.persistence-api 3.2.0 -- it does not exist in 3.1). It lets an enum control its own
* persisted representation (a custom code), instead of ORDINAL or STRING.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
public class EnumeratedValueEntity {
@Id
@GeneratedValue
private Long id;
// @EnumeratedValue's own field type (String) determines the persisted column type, but
// Hibernate still needs to be told this is a STRING-shaped enum, not the ORDINAL default --
// otherwise boot fails with "@EnumeratedValue for EnumType.ORDINAL must be placed on a
// field whose type is byte, short, or int".
@Enumerated(EnumType.STRING)
private Priority priority;
public EnumeratedValueEntity() {
}
public EnumeratedValueEntity(Priority priority) {
this.priority = priority;
}
public Long getId() {
return id;
}
public Priority getPriority() {
return priority;
}
/** Persisted representation is the "code" string, NOT the enum's ordinal or name(). */
public enum Priority {
LOW("L"), MEDIUM("M"), HIGH("H");
@EnumeratedValue
private final String code;
Priority(String code) {
this.code = code;
}
public String getCode() {
return code;
}
}
}
@@ -0,0 +1,47 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.util.Objects;
/**
* The anti-pattern the article warns about in Q5: {@code equals()}/{@code hashCode()} based on
* the SURROGATE {@code @GeneratedValue} id. Before the first flush, {@code id} is null, so the
* object's hash code is fixed at "hash of null" -- then flush() mutates {@code id}, changing the
* hash code out from under any HashSet/HashMap the object is already sitting in.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
public class IdBasedEqualsEntity {
@Id
@GeneratedValue
private Long id;
private String label;
public IdBasedEqualsEntity() {
}
public IdBasedEqualsEntity(String label) {
this.label = label;
}
public Long getId() {
return id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof IdBasedEqualsEntity that)) return false;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
@@ -0,0 +1,41 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
/**
* Deliberately relies on the JPA/Hibernate DEFAULT {@code equals()}/{@code hashCode()}
* (identity-based, inherited from Object) with a surrogate {@code @GeneratedValue} id that is
* null until the first flush. This is the entity used to reproduce the classic
* "HashSet.contains() returns false after persist()" symptom.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
public class IdentityHashSetEntity {
@Id
@GeneratedValue
private Long id;
private String label;
public IdentityHashSetEntity() {
}
public IdentityHashSetEntity(String label) {
this.label = label;
}
public Long getId() {
return id;
}
public String getLabel() {
return label;
}
// Deliberately NOT overriding equals()/hashCode() -- uses Object identity.
// A second class below overrides them using the surrogate id, to show that trap too.
}
@@ -0,0 +1,40 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* {@code @JdbcTypeCode(SqlTypes.JSON)} on a {@code Map<String,Object>} field, tested against
* H2 2.4.240 -- the article recommends it without saying whether it actually works on H2.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
public class JsonColumnEntity {
@Id
@GeneratedValue
private Long id;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> details;
public JsonColumnEntity() {
}
public JsonColumnEntity(Map<String, Object> details) {
this.details = details;
}
public Long getId() {
return id;
}
public Map<String, Object> getDetails() {
return details;
}
}
@@ -0,0 +1,76 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Access;
import jakarta.persistence.AccessType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Transient;
/**
* Default access is FIELD (because {@code @Id} is annotated on the field). One property,
* {@code computedLabel}, is explicitly switched to PROPERTY access via {@code @Access} on its
* getter, with a derivation the FIELD side does not know about. This reproduces the classic
* "mixed access silently reads the wrong value" symptom: a raw field mutation is invisible to
* Hibernate once a property is PROPERTY-access, and vice versa.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
public class MixedAccessEntity {
@Id
@GeneratedValue
private Long id;
private String rawValue;
// FIELD access is the entity default (because @Id is on a field). This backing field is
// deliberately never read directly by Hibernate for the "label" property -- only the
// PROPERTY-access getter below is.
@Transient
private int getterCallCount = 0;
public MixedAccessEntity() {
}
public MixedAccessEntity(String rawValue) {
this.rawValue = rawValue;
}
public Long getId() {
return id;
}
public String getRawValue() {
return rawValue;
}
public void setRawValue(String rawValue) {
this.rawValue = rawValue;
}
/**
* Explicitly PROPERTY access: Hibernate calls this getter (not a field read) to determine
* the persisted value for the "computedLabel" mapped property.
*/
@Access(AccessType.PROPERTY)
@Column(name = "computed_label")
public String getComputedLabel() {
getterCallCount++;
return rawValue == null ? null : rawValue.toUpperCase();
}
// Hibernate's default PROPERTY-access strategy REQUIRES a setter even for a logically
// read-only derived column -- omitting it throws PropertyNotFoundException at boot
// ("Could not locate setter method for property 'computedLabel'"). This setter is a
// deliberate no-op: it exists purely to satisfy that requirement.
public void setComputedLabel(String ignored) {
// no-op: computedLabel is derived from rawValue, never written directly
}
public int getGetterCallCount() {
return getterCallCount;
}
}
@@ -0,0 +1,11 @@
package com.ankurm.hibernatedemo.persistenceannotations;
/**
* A Java record used as a JPQL constructor-expression result type -- new in Jakarta
* Persistence 3.2 (JPQL constructor expressions may target a record, matching a canonical
* constructor by position/type).
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
public record PriorityCountView(EnumeratedValueEntity.Priority priority, long total) {
}
@@ -0,0 +1,42 @@
package com.ankurm.hibernatedemo.persistenceannotations;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import java.time.LocalDate;
/**
* {@code @Temporal} is {@code @Deprecated(since = "3.2")} in jakarta.persistence-api 3.2.0
* (confirmed via javap on the annotation class file). This entity puts it on a
* {@code java.time.LocalDate} field anyway, to see whether Hibernate 7.4.5 ignores it,
* warns, or throws at boot.
*
* <p>Docs: docs/05-jpa-persistence-annotations.md, Topic 2.
*/
@Entity
public class TemporalOnLocalDateEntity {
@Id
@GeneratedValue
private Long id;
@Temporal(TemporalType.DATE)
private LocalDate eventDate;
public TemporalOnLocalDateEntity() {
}
public TemporalOnLocalDateEntity(LocalDate eventDate) {
this.eventDate = eventDate;
}
public Long getId() {
return id;
}
public LocalDate getEventDate() {
return eventDate;
}
}
@@ -0,0 +1,30 @@
package com.ankurm.hibernatedemo.procedure;
/**
* Plain DTO (NOT an @Entity) used as the {@code @ConstructorResult} target for
* {@code EmployeeSummaryMapping} in {@link ProcEmployee}, backing the "result set mapped to a
* DTO via @SqlResultSetMapping" requirement in docs/08-stored-procedures.md.
*/
public class EmployeeSummary {
private final Integer id;
private final String name;
public EmployeeSummary(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
@Override
public String toString() {
return "EmployeeSummary{id=%s, name=%s}".formatted(id, name);
}
}
@@ -0,0 +1,87 @@
package com.ankurm.hibernatedemo.procedure;
import jakarta.persistence.Column;
import jakarta.persistence.ColumnResult;
import jakarta.persistence.ConstructorResult;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.NamedStoredProcedureQueries;
import jakarta.persistence.NamedStoredProcedureQuery;
import jakarta.persistence.ParameterMode;
import jakarta.persistence.SqlResultSetMapping;
import jakarta.persistence.SqlResultSetMappings;
import jakarta.persistence.StoredProcedureParameter;
import jakarta.persistence.Table;
import java.math.BigDecimal;
/**
* Entity for docs/08-stored-procedures.md (merged posts 4867 + 4881). Backed by HSQLDB 2.7.3
* real SQL/PSM stored procedures created in {@code ProcedureSchemaSupport} -- these are not
* described in prose, they are compiled and executed.
*/
@Entity
@Table(name = "PROC_EMPLOYEES")
@NamedStoredProcedureQueries({
@NamedStoredProcedureQuery(
name = "ProcEmployee.getTax",
procedureName = "GET_TAX",
parameters = {
@StoredProcedureParameter(mode = ParameterMode.IN, name = "emp_id", type = Integer.class),
@StoredProcedureParameter(mode = ParameterMode.OUT, name = "tax_amount", type = BigDecimal.class)
}
),
@NamedStoredProcedureQuery(
name = "ProcEmployee.listAll",
procedureName = "LIST_EMPLOYEES",
resultClasses = ProcEmployee.class
)
})
@SqlResultSetMappings({
@SqlResultSetMapping(
name = "EmployeeSummaryMapping",
classes = @ConstructorResult(
targetClass = EmployeeSummary.class,
columns = {
@ColumnResult(name = "ID", type = Integer.class),
@ColumnResult(name = "NAME", type = String.class)
}
)
)
})
public class ProcEmployee {
@Id
private Integer id;
private String name;
@Column(precision = 10, scale = 2)
private BigDecimal salary;
protected ProcEmployee() {
// JPA
}
public ProcEmployee(Integer id, String name, BigDecimal salary) {
this.id = id;
this.name = name;
this.salary = salary;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public BigDecimal getSalary() {
return salary;
}
@Override
public String toString() {
return "ProcEmployee{id=%s, name=%s, salary=%s}".formatted(id, name, salary);
}
}
@@ -0,0 +1,76 @@
package com.ankurm.hibernatedemo.proxy;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedAttributeNode;
import jakarta.persistence.NamedEntityGraph;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
/**
* Backs docs/11-proxies-and-lazy-initialization.md (post 4870 rewrite). Two associations on purpose:
*
* <ul>
* <li>{@code publisher} -- {@code @ManyToOne} with the JPA-default fetch type, EAGER.
* Nothing in this class overrides it.</li>
* <li>{@code reviews} -- {@code @OneToMany}, explicitly LAZY, and the only attribute named
* in {@code Book.reviews-only}. This is the association the fetchgraph/loadgraph demo
* turns on and off.</li>
* </ul>
*
* <p>With a {@code jakarta.persistence.loadgraph} hint carrying {@code Book.reviews-only},
* {@code reviews} gets join-fetched AND {@code publisher} still gets its default EAGER join --
* loadgraph only promotes attributes, it never demotes ones the mapping already marks EAGER.
* With a {@code jakarta.persistence.fetchgraph} hint carrying the same named graph,
* {@code reviews} still gets join-fetched, but {@code publisher} is forced down to LAZY (a
* proxy, no join) even though the mapping says EAGER -- fetchgraph treats the graph as the
* complete fetch plan, not an addition to the mapping's own defaults.
*/
@Entity
@NamedEntityGraph(name = "Book.reviews-only", attributeNodes = @NamedAttributeNode("reviews"))
public class ProxyBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "publisher_id")
private ProxyPublisher publisher;
@OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
private List<ProxyReview> reviews = new ArrayList<>();
protected ProxyBook() {
// JPA
}
public ProxyBook(String title, ProxyPublisher publisher) {
this.title = title;
this.publisher = publisher;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public ProxyPublisher getPublisher() {
return publisher;
}
public List<ProxyReview> getReviews() {
return reviews;
}
}
@@ -0,0 +1,39 @@
package com.ankurm.hibernatedemo.proxy;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
/**
* The default-fetch-type EAGER side of the entity-graph demo in docs/11-proxies-and-lazy-initialization.md
* (fetchgraph vs loadgraph). {@link ProxyBook#publisher} points here with the default
* {@code @ManyToOne} fetch type, which is EAGER -- deliberately, so that a
* {@code jakarta.persistence.fetchgraph} hint has something to force back to LAZY that a
* {@code jakarta.persistence.loadgraph} hint leaves alone.
*/
@Entity
public class ProxyPublisher {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected ProxyPublisher() {
// JPA
}
public ProxyPublisher(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,44 @@
package com.ankurm.hibernatedemo.proxy;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
/**
* Child side of {@link ProxyBook#reviews}. See {@link ProxyBook} for why this association
* exists and how the entity-graph demo in docs/11-proxies-and-lazy-initialization.md uses it.
*/
@Entity
public class ProxyReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String comment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id")
private ProxyBook book;
protected ProxyReview() {
// JPA
}
public ProxyReview(String comment, ProxyBook book) {
this.comment = comment;
this.book = book;
}
public Long getId() {
return id;
}
public String getComment() {
return comment;
}
}
@@ -0,0 +1,49 @@
package com.ankurm.hibernatedemo.query;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
/**
* Shared by the HQL chapter (15) and the Criteria API chapter (16) &mdash; both posts use the
* same Employee/Department pair in their examples, so one real mapping backs both rather than
* two near-duplicates.
*
* <p>Named {@code QueryDept} (entity name, via {@code @Entity(name = ...)}), not {@code
* Department}, because chapter 06's {@code naturalid.Department} already claims that entity
* name in this same persistence unit -- Hibernate requires distinct entity names project-wide,
* not just distinct class names.
*
* <p>Docs: docs/15-hql-queries.md, docs/16-criteria-queries.md.
*/
@Entity(name = "QueryDept")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected Department() {
// JPA
}
public Department(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
@Override
public String toString() {
return "Department{id=%s, name=%s}".formatted(id, name);
}
}
@@ -0,0 +1,97 @@
package com.ankurm.hibernatedemo.query;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.time.LocalDate;
/**
* Shared by the HQL chapter (15) and the Criteria API chapter (16).
*
* <p>Docs: docs/15-hql-queries.md, docs/16-criteria-queries.md.
*
* <p>{@code status} and {@code hireDate} exist specifically for the bulk-update/bulk-delete
* scenarios both chapters cover ({@code UPDATE ... WHERE lastLogin/hireDate < :cutoff}-shaped
* queries), and {@code department} is LAZY so the fetch-join chapter has a real N+1 to avoid
* rather than an asserted one.
*/
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private Double salary;
private String status;
private LocalDate hireDate;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
protected Employee() {
// JPA
}
public Employee(String firstName, String lastName, Double salary, String status, LocalDate hireDate, Department department) {
this.firstName = firstName;
this.lastName = lastName;
this.salary = salary;
this.status = status;
this.hireDate = hireDate;
this.department = department;
}
public Long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public Double getSalary() {
return salary;
}
public void setSalary(Double salary) {
this.salary = salary;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDate getHireDate() {
return hireDate;
}
public Department getDepartment() {
return department;
}
@Override
public String toString() {
return "Employee{id=%s, firstName=%s, lastName=%s, salary=%s, status=%s}"
.formatted(id, firstName, lastName, salary, status);
}
}
@@ -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();
}
}
@@ -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 &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();
}
}
@@ -0,0 +1,76 @@
package com.ankurm.hibernatedemo.testdb;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
/**
* Backs docs/09-testing-in-memory-databases.md (post 4868 rewrite). One entity, run through the SAME mapping
* against H2 2.4.240, HSQLDB 2.7.3 and Apache Derby 10.16.1.1 via {@link TestDbBootstrap} --
* the DDL differences, dialect selection, and cross-database failure case are all read off
* this exact class.
*
* <ul>
* <li>{@code id} -- {@code GenerationType.AUTO}, deliberately not IDENTITY or SEQUENCE, to
* see what each dialect resolves AUTO to.</li>
* <li>{@code sku} -- {@code varchar(5)}, short enough that an over-length value is a real
* constraint violation on at least one of the three databases.</li>
* <li>{@code order} -- a column named after a SQL reserved word on purpose (see
* {@code @Column(name = "\"order\"")}), the classic "works on some databases, breaks on
* others" trap.</li>
* <li>{@code active} -- a plain {@code boolean}, to see how each dialect maps and prints it.</li>
* <li>{@code description} -- {@code @Lob}, to see clob/text mapping differences.</li>
* </ul>
*/
@Entity
public class TestDbWidget {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(length = 5)
private String sku;
@Column(name = "\"order\"")
private Integer order;
private boolean active;
@Lob
private String description;
protected TestDbWidget() {
// JPA
}
public TestDbWidget(String sku, Integer order, boolean active, String description) {
this.sku = sku;
this.order = order;
this.active = active;
this.description = description;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public Integer getOrder() {
return order;
}
public boolean isActive() {
return active;
}
public String getDescription() {
return description;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
version="3.2">
<!-- Backs ankurm.com post 4877, 14-named-queries.md, chapter Named queries in orm.xml -->
<named-query name="XmlQueryEmployee.findBySalaryAboveXml">
<query>SELECT e FROM XmlQueryEmployee e WHERE e.salary > :min ORDER BY e.salary DESC</query>
</named-query>
<!-- Same name as an annotation on XmlQueryEmployee; orm.xml wins over the annotation. -->
<named-query name="XmlQueryEmployee.overridden">
<query>SELECT e FROM XmlQueryEmployee e WHERE e.salary > :min</query>
</named-query>
</entity-mappings>
+43
View File
@@ -0,0 +1,43 @@
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
# Pinned OFF deliberately. hibernate-jcache is on this project's classpath (the
# natural-id L2 chapter needs it), and Hibernate 7.4.5 will resolve a RegionFactory
# through the service loader and turn the second-level cache ON by itself when it
# finds one -- with nothing configured here. That is fine until a standalone test
# closes the shared Ehcache CacheManager, at which point unrelated Spring tests fail
# their commit with "Cache[...] is closed". See docs/output/testdb-jcache-classpath-pollution.txt
# and JCacheOnClasspathAutoEnablesL2Test, which pins the auto-enable down.
cache:
use_second_level_cache: false
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,37 @@
package com.ankurm.brokenprobe;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
/**
* DELIBERATELY BROKEN entity used ONLY by
* {@code com.ankurm.hibernatedemo.namedquery.NamedQueryStartupValidationTest}, via a fully
* standalone Hibernate bootstrap (StandardServiceRegistryBuilder + MetadataSources) that never
* touches the shared Spring Boot application context.
*
* <p>Deliberately placed OUTSIDE the {@code com.ankurm.hibernatedemo} package tree. Spring
* Boot's default JPA entity scanning walks every subpackage of the {@code @SpringBootApplication}
* class's package ({@code com.ankurm.hibernatedemo}), so a broken {@code @NamedQuery} anywhere
* under that tree would fail {@code @SpringBootTest} context bootstrap for EVERY test in this
* shared repository, not just this one. Keeping it here means only the standalone bootstrap
* below ever sees it.
*/
@Entity
@Table(name = "broken_nq_employee")
@NamedQuery(name = "BrokenNamedQueryEmployee.badProperty",
query = "SELECT e FROM BrokenNamedQueryEmployee e WHERE e.firsNam = :name") // typo: firsNam
public class BrokenNamedQueryEmployee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
protected BrokenNamedQueryEmployee() {
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,60 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.immutable.ExchangeRate;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/07-immutable-entities.md. A SEPARATE Spring context (own EntityManagerFactory) with
* {@code hibernate.query.immutable_entity_update_query_handling_mode=allow} set, to prove the
* escape hatch from {@link ImmutableEntityTest#bulkHqlUpdate_onImmutableEntity_isRejectedAtTranslationTime_byDefault}
* really lets the bulk HQL UPDATE run -- and that when it does, the UPDATE is issued for real.
* This is a SessionFactory-wide setting, not a per-query hint, hence the separate context.
*/
@SpringBootTest(properties = {
"spring.jpa.properties.hibernate.query.immutable_entity_update_query_handling_mode=allow"
})
class ImmutableBulkUpdateAllowedTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void withHandlingModeSetToAllow_bulkHqlUpdate_actuallyRunsAgainstImmutableEntity() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("ZAR/USD", new BigDecimal("18.0000"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
int updated = em.createQuery("update ExchangeRate set rate = :r where id = :id")
.setParameter("r", new BigDecimal("1.0000"))
.setParameter("id", id)
.executeUpdate();
em.getTransaction().commit();
em.close();
DEMO.info("with handling-mode=allow, bulk HQL UPDATE rowsAffected={}", updated);
EntityManager verify = emf.createEntityManager();
ExchangeRate reloaded = verify.find(ExchangeRate.class, id);
DEMO.info("row after allowed bulk HQL update: {}", reloaded);
verify.close();
assertThat(updated).isEqualTo(1);
assertThat(reloaded.getRate()).isEqualByComparingTo("1.0000");
}
}
@@ -0,0 +1,330 @@
package com.ankurm.hibernatedemo;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.immutable.AuditTrail;
import com.ankurm.hibernatedemo.immutable.ExchangeRate;
import com.ankurm.hibernatedemo.immutable.ExchangeRateVersioned;
import com.ankurm.hibernatedemo.immutable.PlainRate;
import com.ankurm.hibernatedemo.immutable.RateWithAuditTrail;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Backs docs/07-immutable-entities.md ({@code @Immutable}, post 4866).
* Run with {@code ./mvnw -Dtest=ImmutableEntityTest test}.
*
* <p>Verified on Hibernate ORM 7.4.5.Final / jakarta.persistence-api 3.2.0 / H2 2.4.240.
*/
@SpringBootTest
class ImmutableEntityTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
/**
* THE headline claim: mutate a field on an @Immutable entity, flush inside a transaction --
* no UPDATE is issued, AND no exception is thrown. The silence is the story.
*/
@Test
void mutatingAndFlushing_immutableEntity_issuesNoUpdate_andThrowsNothing() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("USD/EUR", new BigDecimal("0.9200"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
stats().clear();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ExchangeRate managed = em.find(ExchangeRate.class, id);
managed.setRate(new BigDecimal("999.9999"));
DEMO.info("in-memory field mutated to {}, about to flush inside a transaction", managed.getRate());
em.getTransaction().commit(); // flush + commit; must not throw
em.close();
long updateCount = stats().getEntityUpdateCount();
DEMO.info("Statistics.getEntityUpdateCount() after mutate+flush = {}", updateCount);
assertThat(updateCount).isZero();
EntityManager verify = emf.createEntityManager();
ExchangeRate reloaded = verify.find(ExchangeRate.class, id);
DEMO.info("reloaded from DB: {}", reloaded);
assertThat(reloaded.getRate()).isEqualByComparingTo("0.9200");
verify.close();
}
/** @Immutable does not protect against EntityManager.remove() -- the DELETE goes through. */
@Test
void immutableEntity_canStillBeDeleted_viaEntityManagerRemove() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("GBP/USD", new BigDecimal("1.2500"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ExchangeRate managed = em.find(ExchangeRate.class, id);
em.remove(managed);
em.getTransaction().commit();
em.close();
EntityManager verify = emf.createEntityManager();
ExchangeRate gone = verify.find(ExchangeRate.class, id);
DEMO.info("after EntityManager.remove() on an @Immutable entity, find() returns: {}", gone);
assertThat(gone).isNull();
verify.close();
}
/**
* Does a bulk HQL "update ... set" statement respect @Immutable? By DEFAULT in 7.4.5,
* Hibernate refuses to even translate it -- it fails at query-compile time, before hitting
* the database, with an InterpretationException naming the offending entity and the exact
* setting that would allow it through.
*/
@Test
void bulkHqlUpdate_onImmutableEntity_isRejectedAtTranslationTime_byDefault() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("AUD/USD", new BigDecimal("0.6500"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Exception caught = null;
try {
em.createQuery("update ExchangeRate set rate = :r where id = :id")
.setParameter("r", new BigDecimal("42.0000"))
.setParameter("id", id)
.executeUpdate();
} catch (Exception e) {
caught = e;
}
em.getTransaction().rollback();
em.close();
DEMO.info("bulk HQL 'update ExchangeRate set ...' on an @Immutable entity threw: {}: {}",
caught == null ? "NOTHING" : caught.getClass().getName(),
caught == null ? "" : caught.getMessage());
assertThat(caught).isInstanceOf(org.hibernate.query.sqm.InterpretationException.class);
assertThat(caught.getMessage()).contains("attempts to update an immutable entity");
assertThat(caught.getMessage()).contains("immutable_entity_update_query_handling_mode");
}
/** Does a bulk HQL "delete" statement respect @Immutable? */
@Test
void bulkHqlDelete_onImmutableEntity_behaviour() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("NZD/USD", new BigDecimal("0.6100"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
int deleted = em.createQuery("delete from ExchangeRate where id = :id")
.setParameter("id", id)
.executeUpdate();
em.getTransaction().commit();
em.close();
DEMO.info("bulk HQL 'delete from ExchangeRate' executeUpdate() returned rowsAffected={}", deleted);
assertThat(deleted).isEqualTo(1);
EntityManager verify = emf.createEntityManager();
assertThat(verify.find(ExchangeRate.class, id)).isNull();
verify.close();
}
/** Native SQL is below Hibernate's tracking entirely -- @Immutable cannot see it. */
@Test
void nativeSqlUpdate_onImmutableEntity_alwaysWorks() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRate rate = new ExchangeRate("CHF/USD", new BigDecimal("1.1000"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
int rows = em.createNativeQuery("update exchange_rate set rate = 7.7 where id = " + id)
.executeUpdate();
em.getTransaction().commit();
em.close();
DEMO.info("native SQL UPDATE rows={}", rows);
assertThat(rows).isEqualTo(1);
EntityManager verify = emf.createEntityManager();
ExchangeRate reloaded = verify.find(ExchangeRate.class, id);
DEMO.info("row after native SQL update: {}", reloaded);
assertThat(reloaded.getRate()).isEqualByComparingTo("7.7");
verify.close();
}
/**
* @Immutable on a @OneToMany collection: adding to it and flushing has historically thrown a
* specific exception. Capture the exact class and message in Hibernate 7.4.5.
*/
@Test
void addingToImmutableCollection_andFlushing_throwsSpecificException() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
RateWithAuditTrail parent = new RateWithAuditTrail("EUR/JPY");
seed.persist(parent);
seed.getTransaction().commit();
Long id = parent.getId();
seed.close();
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
RateWithAuditTrail managed = em.find(RateWithAuditTrail.class, id);
managed.getAuditTrails().add(new AuditTrail("added after load"));
Exception caught = null;
try {
em.getTransaction().commit();
} catch (Exception e) {
caught = e;
}
Throwable root = caught;
while (root != null && root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
DEMO.info("adding to an @Immutable collection and flushing threw: {}",
caught == null ? "NOTHING" : caught.getClass().getName() + ": " + caught.getMessage());
DEMO.info("root cause class: {} message: {}",
root == null ? "NONE" : root.getClass().getName(),
root == null ? "" : root.getMessage());
if (em.getTransaction().isActive()) {
em.getTransaction().rollback();
}
em.close();
assertThat(caught).isNotNull();
}
/** Does Hibernate accept @Immutable + @Version, and does the version column ever increment? */
@Test
void immutablePlusVersion_isAccepted_butVersionNeverIncrements() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
ExchangeRateVersioned rate = new ExchangeRateVersioned("SEK/USD", new BigDecimal("0.0950"));
seed.persist(rate);
seed.getTransaction().commit();
Long id = rate.getId();
Long initialVersion = rate.getVersion();
seed.close();
DEMO.info("persisted @Immutable+@Version entity, version after insert = {}", initialVersion);
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
ExchangeRateVersioned managed = em.find(ExchangeRateVersioned.class, id);
managed.setRate(new BigDecimal("0.1234"));
em.getTransaction().commit(); // must not throw
em.close();
EntityManager verify = emf.createEntityManager();
ExchangeRateVersioned reloaded = verify.find(ExchangeRateVersioned.class, id);
DEMO.info("after mutate+flush, version = {}, rate = {}", reloaded.getVersion(), reloaded.getRate());
verify.close();
assertThat(reloaded.getVersion()).isEqualTo(initialVersion);
assertThat(reloaded.getRate()).isEqualByComparingTo("0.0950");
}
/**
* Session.setReadOnly(entity, true) on a PLAIN (non-@Immutable) entity: same silent-no-UPDATE
* outcome as @Immutable, but it is a per-entity-instance runtime toggle rather than a
* class-wide, DDL-agnostic mapping annotation.
*/
@Test
void sessionSetReadOnlyOnPlainEntity_alsoSuppressesUpdate_perInstance() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
PlainRate p = new PlainRate("PLN/USD", new BigDecimal("4.0"));
seed.persist(p);
seed.getTransaction().commit();
Long id = p.getId();
seed.close();
stats().clear();
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
PlainRate managed = session.find(PlainRate.class, id);
session.setReadOnly(managed, true);
managed.setRate(new BigDecimal("999"));
em.getTransaction().commit();
em.close();
long updateCount = stats().getEntityUpdateCount();
DEMO.info("Session.setReadOnly(entity,true) then mutate+flush -> entityUpdateCount = {}", updateCount);
assertThat(updateCount).isZero();
EntityManager verify = emf.createEntityManager();
PlainRate reloaded = verify.find(PlainRate.class, id);
assertThat(reloaded.getRate()).isEqualByComparingTo("4.0");
verify.close();
}
/** Session.setDefaultReadOnly(true) applies the read-only flag to every entity loaded afterwards. */
@Test
void sessionSetDefaultReadOnly_appliesToAllSubsequentLoads() {
EntityManager seed = emf.createEntityManager();
seed.getTransaction().begin();
PlainRate p = new PlainRate("DKK/USD", new BigDecimal("6.9"));
seed.persist(p);
seed.getTransaction().commit();
Long id = p.getId();
seed.close();
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
session.setDefaultReadOnly(true);
em.getTransaction().begin();
PlainRate managed = session.find(PlainRate.class, id);
managed.setRate(new BigDecimal("111"));
em.getTransaction().commit();
em.close();
EntityManager verify = emf.createEntityManager();
PlainRate reloaded = verify.find(PlainRate.class, id);
DEMO.info("after setDefaultReadOnly(true) + mutate + flush, rate = {}", reloaded.getRate());
assertThat(reloaded.getRate()).isEqualByComparingTo("6.9");
verify.close();
}
}
@@ -0,0 +1,100 @@
package com.ankurm.hibernatedemo;
import com.ankurm.hibernatedemo.immutable.WideImmutableRow;
import com.ankurm.hibernatedemo.immutable.WideMutableRow;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
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 docs/07-immutable-entities.md, "Does @Immutable actually cost less at flush time?".
*
* <p>This is NOT a rigorous benchmark: it is one Spring context, in a shared container, timed
* with {@code System.nanoTime()} around a single {@code flush()} call per entity type, no JMH,
* no warm-up isolation, no forked JVM. Treat the numbers as indicative of the right order of
* magnitude and direction, not as a citable throughput claim. Rows: 4000 per entity type,
* 12 String columns each, loaded fully into the persistence context, then flushed with NO
* pending changes -- so any time difference is purely Hibernate deciding "does this entity need
* an UPDATE", not the cost of writing one.
*/
@SpringBootTest
class ImmutableFlushCostTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int ROW_COUNT = 4000;
@Autowired
private EntityManagerFactory emf;
@Test
void flushingManyLoadedEntities_immutableSkipsDirtyCheck_mutableDoesNot() {
seedMutable(ROW_COUNT);
seedImmutable(ROW_COUNT);
// Warm-up pass (JIT, connection pool, buffer pool) -- discarded.
timeMutableFlush();
timeImmutableFlush();
long mutableNanos = timeMutableFlush();
long immutableNanos = timeImmutableFlush();
DEMO.info("flush() over {} loaded MUTABLE rows (12 cols, no pending changes): {} ms",
ROW_COUNT, mutableNanos / 1_000_000.0);
DEMO.info("flush() over {} loaded @Immutable rows (12 cols, no pending changes): {} ms",
ROW_COUNT, immutableNanos / 1_000_000.0);
DEMO.info("ratio (mutable / immutable) = {}", (double) mutableNanos / immutableNanos);
DEMO.info("CAVEAT: single-run, shared-container timing -- indicative only, not a benchmark result.");
}
private long timeMutableFlush() {
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
List<WideMutableRow> rows = session.createQuery("from WideMutableRow", WideMutableRow.class).list();
long start = System.nanoTime();
em.flush();
long elapsed = System.nanoTime() - start;
em.getTransaction().rollback();
em.close();
return elapsed;
}
private long timeImmutableFlush() {
EntityManager em = emf.createEntityManager();
Session session = em.unwrap(Session.class);
em.getTransaction().begin();
List<WideImmutableRow> rows = session.createQuery("from WideImmutableRow", WideImmutableRow.class).list();
long start = System.nanoTime();
em.flush();
long elapsed = System.nanoTime() - start;
em.getTransaction().rollback();
em.close();
return elapsed;
}
private void seedMutable(int count) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 0; i < count; i++) {
em.persist(new WideMutableRow("seed-" + i));
}
em.getTransaction().commit();
em.close();
}
private void seedImmutable(int count) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int i = 0; i < count; i++) {
em.persist(new WideImmutableRow("seed-" + i));
}
em.getTransaction().commit();
em.close();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -0,0 +1,181 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
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 4872, docs/12-association-mappings.md chapters "MultipleBagFetchException"
* and "The cartesian-product trap".
*/
@SpringBootTest
class BagFetchTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM BagBookL").executeUpdate();
em.createQuery("DELETE FROM BagAwardL").executeUpdate();
em.createQuery("DELETE FROM BagAuthorList").executeUpdate();
em.createQuery("DELETE FROM BagBookS").executeUpdate();
em.createQuery("DELETE FROM BagAwardS").executeUpdate();
em.createQuery("DELETE FROM BagAuthorSet").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
private Long seedListAuthor(int nBooks, int nAwards) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BagAuthorList author = new BagAuthorList("List Author");
em.persist(author);
for (int i = 0; i < nBooks; i++) {
em.persist(new BagBookL("Book" + i, author));
}
for (int i = 0; i < nAwards; i++) {
em.persist(new BagAwardL("Award" + i, author));
}
em.getTransaction().commit();
Long id = author.getId();
em.close();
return id;
}
private Long seedSetAuthor(int nBooks, int nAwards) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BagAuthorSet author = new BagAuthorSet("Set Author");
em.persist(author);
for (int i = 0; i < nBooks; i++) {
em.persist(new BagBookS("Book" + i, author));
}
for (int i = 0; i < nAwards; i++) {
em.persist(new BagAwardS("Award" + i, author));
}
em.getTransaction().commit();
Long id = author.getId();
em.close();
return id;
}
@Test
void fetchJoiningTwoListsInOneQuery_throwsMultipleBagFetchException() {
seedListAuthor(4, 3);
EntityManager em = emf.createEntityManager();
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () ->
em.createQuery(
"SELECT a FROM BagAuthorList a JOIN FETCH a.books JOIN FETCH a.awards",
BagAuthorList.class).getResultList());
Throwable root = ex;
while (root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
DEMO.info("MultipleBagFetchException reproduction -- wrapper class: {}", ex.getClass().getName());
DEMO.info("MultipleBagFetchException reproduction -- root cause class: {}", root.getClass().getName());
DEMO.info("MultipleBagFetchException reproduction -- verbatim message: {}", root.getMessage());
assertThat(ex.getClass().getName()).as("EntityManager.createQuery(...).getResultList() wraps it as IllegalArgumentException, NOT PersistenceException")
.isEqualTo("java.lang.IllegalArgumentException");
assertThat(root.getClass().getName()).isEqualTo("org.hibernate.loader.MultipleBagFetchException");
em.close();
}
@Test
void fix1_useSetsInsteadOfLists_noExceptionOneQuery() {
seedSetAuthor(4, 3);
EntityManager em = emf.createEntityManager();
stats().clear();
List<BagAuthorSet> authors = em.createQuery(
"SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards",
BagAuthorSet.class)
.getResultList();
long queries = stats().getPrepareStatementCount();
DEMO.info("Fix #1 (Set instead of List): {} distinct authors returned, {} queries fired", authors.size(), queries);
assertThat(authors).hasSize(1);
assertThat(authors.get(0).getBooks()).hasSize(4);
assertThat(authors.get(0).getAwards()).hasSize(3);
assertThat(queries).isEqualTo(1);
em.close();
}
@Test
void fix2_twoSeparateQueries_avoidsBagFetchExceptionEntirely() {
Long id = seedListAuthor(4, 3);
EntityManager em = emf.createEntityManager();
stats().clear();
BagAuthorList author = em.createQuery(
"SELECT a FROM BagAuthorList a JOIN FETCH a.books WHERE a.id = :id", BagAuthorList.class)
.setParameter("id", id)
.getSingleResult();
// second, separate query for the other bag -- no exception because only one JOIN FETCH per query
author = em.createQuery(
"SELECT a FROM BagAuthorList a JOIN FETCH a.awards WHERE a.id = :id", BagAuthorList.class)
.setParameter("id", id)
.getSingleResult();
long queries = stats().getPrepareStatementCount();
DEMO.info("Fix #2 (two queries): {} queries fired, books={}, awards={}",
queries, author.getBooks().size(), author.getAwards().size());
assertThat(author.getBooks()).hasSize(4);
assertThat(author.getAwards()).hasSize(3);
assertThat(queries).as("one JOIN FETCH per query, run twice").isEqualTo(2);
em.close();
}
@Test
void cartesianProduct_fetchJoiningTwoAllowedSetsExplodesRowCount() {
seedSetAuthor(4, 3);
EntityManager em = emf.createEntityManager();
// Raw SQL join without DISTINCT to see the true row count the database returns.
Object rawCount = em.createNativeQuery(
"SELECT COUNT(*) FROM bag_author_set a " +
"JOIN bag_book_s b ON b.author_id = a.id " +
"JOIN bag_award_s w ON w.author_id = a.id")
.getSingleResult();
long rawRowCount = ((Number) rawCount).longValue();
List<BagAuthorSet> entities = em.createQuery(
"SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards",
BagAuthorSet.class)
.getResultList();
DEMO.info("Cartesian product: 4 books x 3 awards for 1 author -> raw SQL join rows = {}, distinct entities returned = {}",
rawRowCount, entities.size());
assertThat(rawRowCount).as("SQL returns one row per (book, award) pair: 4 x 3").isEqualTo(12);
assertThat(entities).as("Hibernate's DISTINCT root-entity de-duplication collapses this back to 1 entity")
.hasSize(1);
assertThat(entities.get(0).getBooks()).hasSize(4);
assertThat(entities.get(0).getAwards()).hasSize(3);
em.close();
}
}
@@ -0,0 +1,188 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
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 4872, docs/12-association-mappings.md chapters "Cascade and orphanRemoval"
* and "The owning side".
*/
@SpringBootTest
class CascadeOrphanTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM CascadeBook").executeUpdate();
em.createQuery("DELETE FROM CascadeAuthor").executeUpdate();
em.createQuery("DELETE FROM NoOrphanBook").executeUpdate();
em.createQuery("DELETE FROM NoOrphanAuthor").executeUpdate();
em.getTransaction().commit();
em.close();
}
@Test
void cascadeAllPlusOrphanRemoval_reassigningTheCollectionThrowsInsteadOfSilentlyDeleting() {
// CORRECTION vs the common blog claim ("silently deletes what you did not expect"):
// assigning a brand new List instance to a CascadeType.ALL + orphanRemoval=true
// collection does NOT silently delete anything -- Hibernate detects the managed
// collection was dereferenced and throws at flush/commit time instead.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
CascadeAuthor author = new CascadeAuthor("Prolific Author");
em.persist(author);
em.persist(new CascadeBook("Book A", author));
em.persist(new CascadeBook("Book B", author));
em.getTransaction().commit();
Long id = author.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
CascadeAuthor managed = em2.find(CascadeAuthor.class, id);
CascadeBook keep = managed.getBooks().get(0);
managed.replaceBooksWithNewList(new ArrayList<>(List.of(keep))); // assigns a NEW ArrayList
jakarta.persistence.RollbackException ex = org.junit.jupiter.api.Assertions.assertThrows(
jakarta.persistence.RollbackException.class, () -> em2.getTransaction().commit());
Throwable root = ex.getCause();
DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- wrapper: {}", ex.getClass().getName());
DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- root cause: {}: {}",
root.getClass().getName(), root.getMessage());
em2.close();
assertThat(root.getClass().getName()).isEqualTo("org.hibernate.HibernateException");
assertThat(root.getMessage()).contains("no longer referenced by the owning entity instance");
}
@Test
void cascadeAllPlusOrphanRemoval_mutatingTheExistingCollectionDoesDeleteUnexpectedly() {
// The scenario that DOES silently delete: mutating the SAME managed collection instance
// (clear() then re-add the ones to keep) rather than reassigning a new List. This is
// the realistic version of the "developer did not expect this" cascade trap.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
CascadeAuthor author = new CascadeAuthor("Prolific Author 2");
em.persist(author);
em.persist(new CascadeBook("Book A", author));
em.persist(new CascadeBook("Book B", author));
em.persist(new CascadeBook("Book C", author));
em.getTransaction().commit();
Long id = author.getId();
em.close();
EntityManager check1 = emf.createEntityManager();
long before = ((Number) check1.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id")
.setParameter("id", id).getSingleResult()).longValue();
check1.close();
assertThat(before).isEqualTo(3);
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
CascadeAuthor managed = em2.find(CascadeAuthor.class, id);
CascadeBook keep = managed.getBooks().stream().filter(b -> b.getTitle().equals("Book A")).findFirst().orElseThrow();
// Mutate the EXISTING Hibernate-managed collection in place -- this is the pattern that
// actually reaches production: "keep only Book A" implemented via removeIf.
managed.getBooks().removeIf(b -> !b.getTitle().equals("Book A"));
em2.getTransaction().commit();
em2.close();
EntityManager check2 = emf.createEntityManager();
long after = ((Number) check2.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id")
.setParameter("id", id).getSingleResult()).longValue();
DEMO.info("cascade=ALL + orphanRemoval=true, in-place removeIf(): books before={}, books after={}", before, after);
check2.close();
assertThat(after)
.as("orphanRemoval=true DELETEd Book B and Book C as soon as they left the managed collection")
.isEqualTo(1);
}
@Test
void noOrphanRemoval_removingFromCollectionAndFlushingDoesNothingToTheRow() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NoOrphanAuthor author = new NoOrphanAuthor("Careful Author");
em.persist(author);
NoOrphanBook book1 = new NoOrphanBook("Keep Me", author);
NoOrphanBook book2 = new NoOrphanBook("Remove Me From Collection", author);
em.persist(book1);
em.persist(book2);
em.getTransaction().commit();
Long authorId = author.getId();
Long book2Id = book2.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
NoOrphanAuthor managed = em2.find(NoOrphanAuthor.class, authorId);
managed.getBooks().removeIf(b -> b.getId().equals(book2Id));
em2.getTransaction().commit(); // no orphanRemoval, no cascade REMOVE -- nothing should happen to book2's row
em2.close();
EntityManager check = emf.createEntityManager();
NoOrphanBook stillThere = check.find(NoOrphanBook.class, book2Id);
DEMO.info("orphanRemoval=false: after removing book2 from author.books and flushing, book2 row still exists = {}, author_id still = {}",
stillThere != null, stillThere == null ? null : stillThere.getAuthor().getId());
check.close();
assertThat(stillThere)
.as("without orphanRemoval or cascade REMOVE, removing a child from the collection does not delete or unlink its row")
.isNotNull();
assertThat(stillThere.getAuthor().getId())
.as("its FK is untouched -- still points at the same author")
.isEqualTo(authorId);
}
@Test
void owningSide_mutatingOnlyTheInverseCollectionNeverWritesTheForeignKey() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NoOrphanAuthor author1 = new NoOrphanAuthor("Author One");
NoOrphanAuthor author2 = new NoOrphanAuthor("Author Two");
em.persist(author1);
em.persist(author2);
NoOrphanBook orphanBook = new NoOrphanBook("Unassigned Book", null);
em.persist(orphanBook);
em.getTransaction().commit();
Long bookId = orphanBook.getId();
Long author2Id = author2.getId();
em.close();
// Mutate ONLY the inverse (mappedBy) side: add the book to author2's collection, but
// never call book.setAuthor(author2) -- the collection is not the owning side.
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
NoOrphanAuthor managedAuthor2 = em2.find(NoOrphanAuthor.class, author2Id);
NoOrphanBook managedBook = em2.find(NoOrphanBook.class, bookId);
managedAuthor2.getBooks().add(managedBook); // inverse side only
em2.getTransaction().commit();
em2.close();
EntityManager check = emf.createEntityManager();
NoOrphanBook reloaded = check.find(NoOrphanBook.class, bookId);
DEMO.info("owning side test: mutated only author2.getBooks().add(book) (inverse side), book.author after flush = {}",
reloaded.getAuthor() == null ? "null (FK not written)" : reloaded.getAuthor().getId());
check.close();
assertThat(reloaded.getAuthor())
.as("mutating only the inverse (mappedBy) collection never persists the association -- the owning side (Book.author) decides the FK")
.isNull();
}
}
@@ -0,0 +1,218 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.TypedQuery;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
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;
import org.springframework.transaction.annotation.Transactional;
/**
* Backs ankurm.com post 4872, docs/12-association-mappings.md chapter "Counting the N+1".
*
* <p>Seeds 100 authors with 3 books each (301 rows total incl. authors) and counts real
* {@code Statistics.getPrepareStatementCount()} for four strategies: naive lazy iteration,
* JPQL fetch join, {@code @EntityGraph}, and {@code @BatchSize(10)}. Run with
* {@code mvn -Dtest=NPlusOneTest test}.
*/
@SpringBootTest
class NPlusOneTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int AUTHOR_COUNT = 100;
private static final int BOOKS_PER_AUTHOR = 3;
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM AssocBook").executeUpdate();
em.createQuery("DELETE FROM AssocAuthor").executeUpdate();
em.createQuery("DELETE FROM BatchBook").executeUpdate();
em.createQuery("DELETE FROM BatchAuthor").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
private void seedPlainAuthors() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int a = 0; a < AUTHOR_COUNT; a++) {
AssocAuthor author = new AssocAuthor("Author-" + a);
em.persist(author);
for (int b = 0; b < BOOKS_PER_AUTHOR; b++) {
em.persist(new AssocBook("Book-" + a + "-" + b, author));
}
}
em.getTransaction().commit();
em.close();
}
private void seedBatchAuthors() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int a = 0; a < AUTHOR_COUNT; a++) {
BatchAuthor author = new BatchAuthor("BatchAuthor-" + a);
em.persist(author);
for (int b = 0; b < BOOKS_PER_AUTHOR; b++) {
em.persist(new BatchBook("BatchBook-" + a + "-" + b, author));
}
}
em.getTransaction().commit();
em.close();
}
@Test
void naiveLazyIteration_firesOneQueryPerAuthor_theClassicNPlusOne() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<AssocAuthor> authors = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size(); // triggers one SELECT per author
}
long queries = stats().getPrepareStatementCount();
DEMO.info("NAIVE lazy iteration: {} authors, {} queries (1 for authors + {} for their book collections), books touched = {}",
authors.size(), queries, authors.size(), booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries)
.as("1 query to load authors + 1 per author to lazily load its books == N+1")
.isEqualTo(1 + AUTHOR_COUNT);
em.close();
}
@Test
void jpqlFetchJoin_firesExactlyOneQuery() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<AssocAuthor> authors = em.createQuery(
"SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class)
.getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size(); // already initialised, no extra SELECT
}
long queries = stats().getPrepareStatementCount();
DEMO.info("JPQL JOIN FETCH: {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("fetch join collapses N+1 into a single query").isEqualTo(1);
em.close();
}
@Test
void entityGraph_firesExactlyOneQuery() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
EntityGraph<?> graph = em.getEntityGraph("AssocAuthor.books");
TypedQuery<AssocAuthor> query = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class);
query.setHint("jakarta.persistence.fetchgraph", graph);
List<AssocAuthor> authors = query.getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size();
}
long queries = stats().getPrepareStatementCount();
DEMO.info("@EntityGraph (fetchgraph hint): {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("entity graph fetch also collapses to a single query").isEqualTo(1);
em.close();
}
@Test
void batchSize10_collapsesNPlusOneIntoCeilNOverBatchSizePlusOne() {
seedBatchAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<BatchAuthor> authors = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList();
long booksTouched = 0;
for (BatchAuthor author : authors) {
booksTouched += author.getBooks().size(); // triggers 1 SELECT per 10 authors (batch fetch)
}
long queries = stats().getPrepareStatementCount();
// ceil(100 / 10) = 10 batch selects + 1 for the authors themselves = 11
long expected = 1 + (long) Math.ceil(AUTHOR_COUNT / 10.0);
DEMO.info("@BatchSize(10): {} authors, {} queries (expected {} = 1 + ceil({}/10)), books touched = {}",
authors.size(), queries, expected, AUTHOR_COUNT, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("BatchSize(10) turns 101 queries into ceil(100/10)+1 = 11").isEqualTo(expected);
em.close();
}
@Test
void printSideBySideComparisonTable() {
// Re-derive each number in one place so the article table and the assertions above can
// never silently drift apart.
seedPlainAuthors();
seedBatchAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List<AssocAuthor> naive = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList();
naive.forEach(a -> a.getBooks().size());
long naiveQ = stats().getPrepareStatementCount();
stats().clear();
List<AssocAuthor> fetchJoin = em.createQuery(
"SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class)
.getResultList();
fetchJoin.forEach(a -> a.getBooks().size());
long fetchJoinQ = stats().getPrepareStatementCount();
stats().clear();
EntityGraph<?> graph = em.getEntityGraph("AssocAuthor.books");
TypedQuery<AssocAuthor> egQuery = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class);
egQuery.setHint("jakarta.persistence.fetchgraph", graph);
List<AssocAuthor> eg = egQuery.getResultList();
eg.forEach(a -> a.getBooks().size());
long egQ = stats().getPrepareStatementCount();
stats().clear();
List<BatchAuthor> batched = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList();
batched.forEach(a -> a.getBooks().size());
long batchQ = stats().getPrepareStatementCount();
DEMO.info("=== Side-by-side query counts for {} authors x {} books each ===", AUTHOR_COUNT, BOOKS_PER_AUTHOR);
DEMO.info("naive lazy iteration : {} queries", naiveQ);
DEMO.info("JPQL JOIN FETCH : {} queries", fetchJoinQ);
DEMO.info("@EntityGraph : {} queries", egQ);
DEMO.info("@BatchSize(10) : {} queries", batchQ);
assertThat(Map.of("naive", naiveQ, "fetchJoin", fetchJoinQ, "entityGraph", egQ, "batch10", batchQ))
.isEqualTo(Map.of("naive", 101L, "fetchJoin", 1L, "entityGraph", 1L, "batch10", 11L));
em.close();
}
}
@@ -0,0 +1,115 @@
package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
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 4872, docs/12-association-mappings.md chapter "The @OneToOne lazy trap".
*
* <p>{@link LazyUser#getProfile()} is the non-owning ("mappedBy") side of an optional
* {@code @OneToOne}, declared {@code FetchType.LAZY}. Without bytecode enhancement, Hibernate
* cannot build a lazy proxy for it (it has no FK column to defer against), so it fires an
* eager extra SELECT regardless of the annotation. {@link MiUser}/{@link MiProfile} show the
* {@code @MapsId} fix: don't map the inverse side at all, look the child up on demand by the
* shared primary key.
*/
@SpringBootTest
class OneToOneLazyTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM LazyProfile").executeUpdate();
em.createQuery("DELETE FROM LazyUser").executeUpdate();
em.createQuery("DELETE FROM MiProfile").executeUpdate();
em.createQuery("DELETE FROM MiUser").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
@Test
void mappedBySideOneToOne_firesExtraSelectEvenThoughItIsDeclaredLazy() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
LazyUser user = new LazyUser("alice");
em.persist(user);
LazyProfile profile = new LazyProfile("bio text", user);
user.setProfile(profile);
em.persist(profile);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
stats().clear();
LazyUser loaded = em2.find(LazyUser.class, id);
long queriesAfterFind = stats().getPrepareStatementCount();
DEMO.info("LazyUser.find(): {} queries fired BEFORE touching getProfile() at all (expected 2: user + eager profile join/select)", queriesAfterFind);
// Note: the extra SELECT for profile already happened during find(), not here.
String bio = loaded.getProfile() == null ? null : "loaded";
long queriesAfterAccess = stats().getPrepareStatementCount();
DEMO.info("after touching getProfile(): {} queries total (profile={})", queriesAfterAccess, bio);
assertThat(queriesAfterFind)
.as("mappedBy @OneToOne(LAZY) still fires the profile SELECT immediately during find(), not on first access")
.isEqualTo(2);
assertThat(queriesAfterAccess).as("no further query needed -- it already ran eagerly").isEqualTo(2);
em2.close();
}
@Test
void mapsIdFix_loadingUserAloneFiresOnlyOneQuery_profileFetchedOnDemand() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
MiUser user = new MiUser("bob");
em.persist(user);
em.flush(); // need the generated id before building the shared-PK child
MiProfile profile = new MiProfile("bob's bio", user);
em.persist(profile);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
stats().clear();
MiUser loaded = em2.find(MiUser.class, id);
long queriesForUserOnly = stats().getPrepareStatementCount();
DEMO.info("MiUser.find() (no mappedBy field at all): {} query", queriesForUserOnly);
// Fetch profile ONLY when actually needed, using the shared PK -- no proxy required.
MiProfile profileOnDemand = em2.find(MiProfile.class, id);
long queriesAfterExplicitProfileFetch = stats().getPrepareStatementCount();
DEMO.info("explicit MiProfile.find() by shared PK when actually needed: {} total queries", queriesAfterExplicitProfileFetch);
assertThat(loaded).isNotNull();
assertThat(queriesForUserOnly)
.as("with @MapsId and no inverse mappedBy field, loading the user alone costs exactly 1 query")
.isEqualTo(1);
assertThat(profileOnDemand.getId()).isEqualTo(id);
assertThat(queriesAfterExplicitProfileFetch)
.as("fetching the profile only happens when the code actually asks for it")
.isEqualTo(2);
em2.close();
}
}
@@ -0,0 +1,152 @@
package com.ankurm.hibernatedemo.bootstrap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import jakarta.persistence.PersistenceConfiguration;
import jakarta.persistence.PersistenceException;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Backs ankurm.com post 4855 (bootstrapping EntityManager). Docs: docs/17-entitymanager-bootstrap.md.
*
* <p>Deliberately plain JUnit, NOT {@code @SpringBootTest} -- the whole point of this chapter is
* bootstrapping a raw JPA {@link EntityManagerFactory} with no Spring involved, exactly as a
* Java SE application or a unit test outside a Spring context would. The XML path reads
* {@code src/test/resources/META-INF/persistence.xml}; the programmatic path uses Jakarta
* Persistence 3.2's {@link PersistenceConfiguration}, new in this version.
*
* <p>Run with {@code ./mvnw -Dtest=EntityManagerBootstrapTest test}.
*/
class EntityManagerBootstrapTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Test
void xmlBootstrap_createsFactoryFromPersistenceXmlAndPersistsAUser() {
// No properties map passed here at all -- everything comes from
// META-INF/persistence.xml on the test classpath, resolved purely by unit name.
try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("XmlBootstrapPU")) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BootstrapUser user = new BootstrapUser("Ankur", "[email protected]");
em.persist(user);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager reader = emf.createEntityManager();
BootstrapUser reloaded = reader.find(BootstrapUser.class, id);
assertThat(reloaded).isNotNull();
assertThat(reloaded.getEmail()).isEqualTo("[email protected]");
reader.close();
DEMO.info("xmlBootstrap: persisted and reloaded user id={}", id);
}
}
@Test
void programmaticBootstrap_needsNoPersistenceXmlUnitOnClasspath() {
// "ProgrammaticPU" has NO matching <persistence-unit> anywhere in persistence.xml --
// this only works at all if PersistenceConfiguration genuinely bypasses XML lookup.
PersistenceConfiguration config = new PersistenceConfiguration("ProgrammaticPU")
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
.managedClass(BootstrapUser.class)
.property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver")
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-programmatic;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.property("hibernate.hbm2ddl.auto", "create-drop");
try (EntityManagerFactory emf = config.createEntityManagerFactory()) {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BootstrapUser user = new BootstrapUser("Programmatic", "[email protected]");
em.persist(user);
em.getTransaction().commit();
Long id = user.getId();
em.close();
EntityManager reader = emf.createEntityManager();
assertThat(reader.find(BootstrapUser.class, id)).isNotNull();
reader.close();
DEMO.info("programmaticBootstrap: persisted user id={} with zero persistence.xml units named 'ProgrammaticPU'", id);
}
}
@Test
void persistenceUnitName_inProgrammaticConfig_doesNotTriggerXmlSearch() {
// Reusing "XmlBootstrapPU" -- the EXACT SAME name as the real XML unit -- but with
// completely different properties built purely in code, pointed at a third database.
// If the name alone triggered an XML lookup/merge, this factory would end up
// connected to bootstrap-xml (the XML unit's database) instead. It doesn't.
PersistenceConfiguration config = new PersistenceConfiguration("XmlBootstrapPU")
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
.managedClass(BootstrapUser.class)
.property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver")
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-namecollision;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.property("hibernate.hbm2ddl.auto", "create-drop");
try (EntityManagerFactory emf = config.createEntityManagerFactory()) {
EntityManager em = emf.createEntityManager();
@SuppressWarnings("unchecked")
java.util.List<Object> row = em.createNativeQuery("SELECT DATABASE()").getResultList();
String actualDb = (String) row.get(0);
assertThat(actualDb).as("the programmatic config's own JDBC URL won, not the XML unit's")
.isEqualToIgnoringCase("BOOTSTRAP-NAMECOLLISION");
em.close();
DEMO.info("persistenceUnitNameCollision: connected database = {} (unit name 'XmlBootstrapPU' reused on purpose)", actualDb);
}
}
@Test
void unconfiguredUnitName_failsWithPersistenceException_notSilently() {
// "TotallyUnknownPU" exists neither in persistence.xml nor as a PersistenceConfiguration
// -- Persistence.createEntityManagerFactory(name) with no config map has nowhere left
// to look.
assertThatThrownBy(() -> Persistence.createEntityManagerFactory("TotallyUnknownPU"))
.isInstanceOf(PersistenceException.class)
.satisfies(ex -> {
DEMO.info("unconfiguredUnitName: {}: {}", ex.getClass().getName(), ex.getMessage());
assertThat(ex.getMessage()).contains("TotallyUnknownPU");
});
}
@Test
void repeatedFactoryCreation_isMeasurablyExpensive() {
// Not a Metaspace OOM reproduction -- that needs sustained, uncollectable class-loader
// growth over many thousands of factories and isn't something to actually trigger in a
// shared CI sandbox. What IS safely measurable: EntityManagerFactory creation is not
// cheap, which is the whole reason "never create one per request" is the rule in the
// first place.
PersistenceConfiguration config = new PersistenceConfiguration("TimingPU")
.provider("org.hibernate.jpa.HibernatePersistenceProvider")
.managedClass(BootstrapUser.class)
.property(PersistenceConfiguration.JDBC_DRIVER, "org.h2.Driver")
.property(PersistenceConfiguration.JDBC_URL, "jdbc:h2:mem:bootstrap-timing;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.property("hibernate.hbm2ddl.auto", "create-drop");
long factoryStart = System.nanoTime();
EntityManagerFactory emf = config.createEntityManagerFactory();
long factoryMillis = (System.nanoTime() - factoryStart) / 1_000_000;
long emStart = System.nanoTime();
EntityManager em = emf.createEntityManager();
long emMillis = (System.nanoTime() - emStart) / 1_000_000;
em.close();
emf.close();
assertThat(factoryMillis).as("factory creation happened and was timed").isGreaterThanOrEqualTo(0);
DEMO.info("repeatedFactoryCreation: createEntityManagerFactory() took {} ms, createEntityManager() took {} ms -- "
+ "the factory call is the one doing schema validation, service registry bootstrap, and metadata scanning; "
+ "the EntityManager call is comparatively trivial", factoryMillis, emMillis);
}
}
@@ -0,0 +1,83 @@
package com.ankurm.hibernatedemo.datetime;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;
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 4876, docs/13-date-and-time-mapping.md chapter "Basic temporal types round trip".
* The generated DDL for {@code temporal_types} (visible in the captured log because
* {@code show_sql=true}) is the primary artifact here.
*/
@SpringBootTest
class BasicTemporalTypesTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void roundTripEveryBasicTemporalType() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
TemporalTypesEntity e = new TemporalTypesEntity();
e.setLocalDate(LocalDate.of(2026, 3, 15));
e.setLocalDateTime(LocalDateTime.of(2026, 3, 15, 10, 30, 45));
e.setLocalTime(LocalTime.of(10, 30, 45));
e.setInstant(Instant.parse("2026-03-15T10:30:45Z"));
e.setOffsetDateTime(OffsetDateTime.of(2026, 3, 15, 10, 30, 45, 0, ZoneOffset.ofHoursMinutes(5, 30)));
e.setZonedDateTime(ZonedDateTime.of(2026, 3, 15, 10, 30, 45, 0, java.time.ZoneId.of("Europe/Paris")));
Calendar cal = new GregorianCalendar(2026, Calendar.MARCH, 15, 10, 30, 45);
Date legacyDate = cal.getTime();
e.setLegacyDateAsDate(legacyDate);
e.setLegacyDateAsTimestamp(legacyDate);
e.setLegacyDateNoTemporal(legacyDate);
e.setLegacyCalendar(cal);
em.persist(e);
em.getTransaction().commit();
Long id = e.getId();
em.close();
EntityManager em2 = emf.createEntityManager();
TemporalTypesEntity loaded = em2.find(TemporalTypesEntity.class, id);
DEMO.info("ROUNDTRIP localDate = {}", loaded.getLocalDate());
DEMO.info("ROUNDTRIP localDateTime = {}", loaded.getLocalDateTime());
DEMO.info("ROUNDTRIP localTime = {}", loaded.getLocalTime());
DEMO.info("ROUNDTRIP instant = {}", loaded.getInstant());
DEMO.info("ROUNDTRIP offsetDateTime = {}", loaded.getOffsetDateTime());
DEMO.info("ROUNDTRIP zonedDateTime = {}", loaded.getZonedDateTime());
DEMO.info("ROUNDTRIP legacyDateAsDate = {}", loaded.getLegacyDateAsDate());
DEMO.info("ROUNDTRIP legacyDateAsTimestamp = {}", loaded.getLegacyDateAsTimestamp());
DEMO.info("ROUNDTRIP legacyDateNoTemporal = {} (class={})", loaded.getLegacyDateNoTemporal(), loaded.getLegacyDateNoTemporal().getClass());
DEMO.info("ROUNDTRIP legacyCalendar = {}", loaded.getLegacyCalendar() == null ? null : loaded.getLegacyCalendar().getTime());
DEMO.info("JVM default timezone during this run = {}", TimeZone.getDefault().getID());
assertThat(loaded.getLocalDate()).isEqualTo(LocalDate.of(2026, 3, 15));
assertThat(loaded.getLocalTime()).isEqualTo(LocalTime.of(10, 30, 45));
assertThat(loaded.getInstant()).isEqualTo(Instant.parse("2026-03-15T10:30:45Z"));
assertThat(loaded.getZonedDateTime().toInstant()).isEqualTo(e.getZonedDateTime().toInstant());
em2.close();
}
}
@@ -0,0 +1,74 @@
package com.ankurm.hibernatedemo.datetime;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
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;
import org.springframework.test.context.TestPropertySource;
/**
* Backs ankurm.com post 4876, docs/13-date-and-time-mapping.md chapter "hibernate.jdbc.time_zone".
* Sets {@code hibernate.jdbc.time_zone=America/New_York} (JVM default left at whatever the
* build runs under) and observes: (1) what it does to a {@code LocalDateTime} (no offset of
* its own -- the JDBC time zone controls what wall-clock value is actually sent to the driver),
* and (2) whether it changes anything for a {@code TimeZoneStorage.NATIVE} OffsetDateTime,
* which already carries its own explicit offset.
*/
@SpringBootTest
@TestPropertySource(properties = {
"spring.jpa.properties.hibernate.jdbc.time_zone=America/New_York"
})
class JdbcTimeZoneTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
@Autowired
private EntityManagerFactory emf;
@Test
void jdbcTimeZone_affectsLocalDateTime_doesNotAffectExplicitOffset() {
LocalDateTime localDateTime = LocalDateTime.of(2026, 7, 4, 9, 0, 0);
OffsetDateTime explicitOffset = OffsetDateTime.of(2026, 7, 4, 9, 0, 0, 0, ZoneOffset.ofHoursMinutes(5, 30));
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
NanoPrecisionEntity np = new NanoPrecisionEntity();
np.setPlainLocalDateTime(localDateTime);
em.persist(np);
TimeZoneStorageEntity tz = new TimeZoneStorageEntity();
tz.setNativeMode(explicitOffset);
em.persist(tz);
em.getTransaction().commit();
Long npId = np.getId();
Long tzId = tz.getId();
em.close();
// Read back the raw stored value via native SQL to see the literal DB representation.
EntityManager em2 = emf.createEntityManager();
Object rawLocalDateTime = em2.createNativeQuery(
"SELECT CAST(plain_local_date_time AS VARCHAR) FROM nano_precision WHERE id = :id")
.setParameter("id", npId).getSingleResult();
Object rawOffset = em2.createNativeQuery(
"SELECT CAST(native_col AS VARCHAR) FROM tz_storage WHERE id = :id")
.setParameter("id", tzId).getSingleResult();
NanoPrecisionEntity npLoaded = em2.find(NanoPrecisionEntity.class, npId);
TimeZoneStorageEntity tzLoaded = em2.find(TimeZoneStorageEntity.class, tzId);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- original LocalDateTime = {}", localDateTime);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- raw DB value for LocalDateTime column = {}", rawLocalDateTime);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- round-tripped LocalDateTime = {}", npLoaded.getPlainLocalDateTime());
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- original OffsetDateTime (NATIVE) = {}", explicitOffset);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- raw DB value for NATIVE offset column = {}", rawOffset);
DEMO.info("hibernate.jdbc.time_zone=America/New_York -- round-tripped OffsetDateTime (NATIVE) = {}", tzLoaded.getNativeMode());
em2.close();
}
}

Some files were not shown because too many files have changed in this diff Show More