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:
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.hibernatedemo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Entry point for the companion demos behind three ankurm.com Hibernate 7 posts:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code get-vs-load} — docs/01-get-vs-load.md, {@link com.ankurm.hibernatedemo.scenario.GetVsLoadRunner}</li>
|
||||
* <li>{@code merge-vs-refresh} — docs/02-merge-vs-refresh.md, {@link com.ankurm.hibernatedemo.scenario.MergeVsRefreshRunner}</li>
|
||||
* <li>{@code insert-identity} / {@code insert-sequence} — docs/03-inserting-objects.md,
|
||||
* {@link com.ankurm.hibernatedemo.scenario.InsertIdentityRunner} and
|
||||
* {@link com.ankurm.hibernatedemo.scenario.InsertSequenceRunner}</li>
|
||||
* </ul>
|
||||
*
|
||||
* Each scenario is a profile-gated {@link org.springframework.boot.CommandLineRunner} that runs
|
||||
* once against an in-memory H2 database and exits — there is no web server to keep alive,
|
||||
* so {@code scripts/run.sh <profile>} is a plain foreground {@code mvn spring-boot:run} call.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class HibernateDemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(HibernateDemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -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 — {@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 — 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)} — 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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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
|
||||
* <natural-id> 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
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Version;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The entity used by {@code get-vs-load} and {@code merge-vs-refresh}.
|
||||
*
|
||||
* <p>Docs: docs/01-get-vs-load.md, docs/02-merge-vs-refresh.md.
|
||||
*
|
||||
* <p>Carries a {@code @Version} column on purpose — the merge/refresh tests need a real
|
||||
* optimistic-lock field to show what merge() does when the version it is holding is stale, not
|
||||
* just what it does to a plain column. The {@code status} field exists specifically for
|
||||
* {@code MergeRefreshTest#refreshDiscardsUnflushedEditSilently}, framed as the
|
||||
* "USER_EDIT" vs "ADMIN_EDIT" scenario in docs/02-merge-vs-refresh.md. The
|
||||
* {@code notes} collection is LAZY and cascades MERGE only — it exists solely for
|
||||
* {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized}, which shows that an
|
||||
* unfetched collection is never navigated during merge().
|
||||
*/
|
||||
@Entity
|
||||
public class Book {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
|
||||
private Long id;
|
||||
|
||||
private String title;
|
||||
|
||||
private String author;
|
||||
|
||||
private String status;
|
||||
|
||||
@Version
|
||||
private Long version;
|
||||
|
||||
@OneToMany(mappedBy = "book", cascade = CascadeType.MERGE, fetch = FetchType.LAZY)
|
||||
private List<Note> notes = new ArrayList<>();
|
||||
|
||||
protected Book() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public Book(String title, String author) {
|
||||
this.title = title;
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public void setAuthor(String author) {
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public List<Note> getNotes() {
|
||||
return notes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Book{id=%s, title=%s, author=%s, status=%s, version=%s}"
|
||||
.formatted(id, title, author, status, version);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
|
||||
/**
|
||||
* Child of {@link Book}, used only by {@code MergeRefreshTest#mergeDoesNotRequireLazyCollectionToBeInitialized}
|
||||
* (docs/02-merge-vs-refresh.md) to show that an unfetched {@code LAZY} collection is not
|
||||
* navigated -- and therefore cannot fail with {@code LazyInitializationException} -- during
|
||||
* {@code merge()} of the owning detached entity.
|
||||
*/
|
||||
@Entity
|
||||
public class Note {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String text;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "book_id")
|
||||
private Book book;
|
||||
|
||||
protected Note() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public Note(String text, Book book) {
|
||||
this.text = text;
|
||||
this.book = book;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* One of four otherwise-identical entities ({@link WidgetAlloc1}, {@link WidgetAlloc10},
|
||||
* {@link WidgetAlloc25}, {@link WidgetAlloc50}) used only by
|
||||
* {@code AllocationSizeSweepTest} to isolate the effect of {@code allocationSize} on
|
||||
* {@code prepareStatementCount} while {@code hibernate.jdbc.batch_size} is held fixed at 25.
|
||||
* Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetAlloc1 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc1_seq")
|
||||
@SequenceGenerator(name = "widget_alloc1_seq", sequenceName = "widget_alloc1_seq", allocationSize = 1)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetAlloc1() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetAlloc1(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* See {@link WidgetAlloc1} — same purpose, {@code allocationSize = 10}.
|
||||
* Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetAlloc10 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc10_seq")
|
||||
@SequenceGenerator(name = "widget_alloc10_seq", sequenceName = "widget_alloc10_seq", allocationSize = 10)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetAlloc10() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetAlloc10(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* See {@link WidgetAlloc1} — same purpose, {@code allocationSize = 25} (matches
|
||||
* {@code hibernate.jdbc.batch_size} in the sweep, same as {@link WidgetSequence}).
|
||||
* Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetAlloc25 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc25_seq")
|
||||
@SequenceGenerator(name = "widget_alloc25_seq", sequenceName = "widget_alloc25_seq", allocationSize = 25)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetAlloc25() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetAlloc25(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* See {@link WidgetAlloc1} — same purpose, {@code allocationSize = 50} (JPA's default).
|
||||
* Reused by {@code BatchSizeSweepTest} to hold {@code allocationSize} fixed at 50 while
|
||||
* {@code hibernate.jdbc.batch_size} varies. Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetAlloc50 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_alloc50_seq")
|
||||
@SequenceGenerator(name = "widget_alloc50_seq", sequenceName = "widget_alloc50_seq", allocationSize = 50)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetAlloc50() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetAlloc50(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* Used exclusively by {@code BatchSizeSweepTest.BatchSize1}. See {@link WidgetBatchSweep50}'s
|
||||
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
|
||||
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetBatchSweep1 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
|
||||
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetBatchSweep1() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetBatchSweep1(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* Used exclusively by {@code BatchSizeSweepTest.BatchSize10}. See {@link WidgetBatchSweep50}'s
|
||||
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
|
||||
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetBatchSweep10 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
|
||||
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetBatchSweep10() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetBatchSweep10(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* Used exclusively by {@code BatchSizeSweepTest.BatchSize25}. See {@link WidgetBatchSweep50}'s
|
||||
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
|
||||
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetBatchSweep25 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
|
||||
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetBatchSweep25() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetBatchSweep25(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* Used exclusively by {@code BatchSizeSweepTest.BatchSize50}. See {@link WidgetBatchSweep50}'s
|
||||
* Javadoc for why each batch_size sweep point gets its own entity and sequence rather than
|
||||
* sharing one across nested test classes. Docs: docs/03-inserting-objects.md.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetBatchSweep50 {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_batch_sweep1_seq")
|
||||
@SequenceGenerator(name = "widget_batch_sweep1_seq", sequenceName = "widget_batch_sweep1_seq", allocationSize = 50)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetBatchSweep50() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetBatchSweep50(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
|
||||
/**
|
||||
* Insert-scenario twin of {@link WidgetSequence}, identical except for the id generation
|
||||
* strategy. Docs: docs/03-inserting-objects.md.
|
||||
*
|
||||
* <p>{@code IDENTITY} requires the database to hand back the generated key on every single
|
||||
* insert, which is exactly why it defeats JDBC batching — see the captured output in
|
||||
* docs/output/insert-identity.txt versus docs/output/insert-sequence.txt for the same
|
||||
* {@code hibernate.jdbc.batch_size} setting producing very different behaviour.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetIdentity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetIdentity() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetIdentity(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.ankurm.hibernatedemo.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.SequenceGenerator;
|
||||
|
||||
/**
|
||||
* Insert-scenario twin of {@link WidgetIdentity}. Docs: docs/03-inserting-objects.md.
|
||||
*
|
||||
* <p>{@code allocationSize} matches {@code hibernate.jdbc.batch_size} in
|
||||
* {@code application-insert-sequence.yml} on purpose: a mismatched allocation size is its own
|
||||
* classic footgun (extra round trips to refill the sequence pool mid-batch) and not one this
|
||||
* repo is trying to demonstrate here.
|
||||
*/
|
||||
@Entity
|
||||
public class WidgetSequence {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "widget_seq")
|
||||
@SequenceGenerator(name = "widget_seq", sequenceName = "widget_seq", allocationSize = 25)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
protected WidgetSequence() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public WidgetSequence(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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) {
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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.
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {
|
||||
}
|
||||
Executable
+42
@@ -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) — 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 — there is no shared session
|
||||
* whose first-level cache could quietly answer a later {@code get()} for free.
|
||||
*/
|
||||
@Component
|
||||
@Profile("getvsload")
|
||||
public class GetVsLoadRunner implements CommandLineRunner {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
|
||||
private final EntityManagerFactory emf;
|
||||
|
||||
public GetVsLoadRunner(EntityManagerFactory emf) {
|
||||
this.emf = emf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Long existingId = seedOneBook();
|
||||
long missingId = existingId + 999_000L;
|
||||
|
||||
step1_getExisting(existingId);
|
||||
step2_getMissing(missingId);
|
||||
step3_getReferenceExisting_noSelectUntilAccessed(existingId);
|
||||
step4_getReferenceMissing_exceptionOnlyOnAccess(missingId);
|
||||
step5_getReferenceThenSessionClosed_lazyInitException(existingId);
|
||||
step6_proxyVsRealIdentity(existingId);
|
||||
}
|
||||
|
||||
private Long seedOneBook() {
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Book book = new Book("Effective Java", "Joshua Bloch");
|
||||
em.persist(book);
|
||||
em.getTransaction().commit();
|
||||
Long id = book.getId();
|
||||
em.close();
|
||||
DEMO.info("SEED: inserted Book id={}", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void step1_getExisting(Long id) {
|
||||
DEMO.info("--- Step 1: session.get() on an existing id ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
DEMO.info("about to call session.get(Book.class, {})", id);
|
||||
Book book = session.get(Book.class, id);
|
||||
DEMO.info("get() returned: {}", book);
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
}
|
||||
|
||||
private void step2_getMissing(long missingId) {
|
||||
DEMO.info("--- Step 2: session.get() on a missing id ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
DEMO.info("about to call session.get(Book.class, {})", missingId);
|
||||
Book book = session.get(Book.class, missingId);
|
||||
DEMO.info("get() returned: {} (no exception thrown)", book);
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
}
|
||||
|
||||
private void step3_getReferenceExisting_noSelectUntilAccessed(Long id) {
|
||||
DEMO.info("--- Step 3: session.getReference() on an existing id ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
Book proxy = session.getReference(Book.class, id);
|
||||
DEMO.info("getReference() returned proxy of class {} -- no SELECT above this line", proxy.getClass().getName());
|
||||
DEMO.info("now calling proxy.getTitle() ...");
|
||||
String title = proxy.getTitle();
|
||||
DEMO.info("getTitle() returned '{}' -- the SELECT for this ran just above this line", title);
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
}
|
||||
|
||||
private void step4_getReferenceMissing_exceptionOnlyOnAccess(long missingId) {
|
||||
DEMO.info("--- Step 4: session.getReference() on a missing id ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
Book proxy = session.getReference(Book.class, missingId);
|
||||
DEMO.info("getReference() returned a proxy for a row that does not exist -- no exception yet: {}", proxy.getClass().getName());
|
||||
try {
|
||||
proxy.getTitle();
|
||||
DEMO.info("no exception -- this line should be unreachable");
|
||||
} catch (RuntimeException e) {
|
||||
DEMO.info("accessing the proxy threw {}: {}", e.getClass().getName(), e.getMessage());
|
||||
}
|
||||
em.getTransaction().rollback();
|
||||
em.close();
|
||||
}
|
||||
|
||||
private void step5_getReferenceThenSessionClosed_lazyInitException(Long id) {
|
||||
DEMO.info("--- Step 5: proxy accessed after its session is closed ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
Book proxy = session.getReference(Book.class, id);
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
DEMO.info("session closed. proxy in hand: {}", proxy.getClass().getName());
|
||||
try {
|
||||
proxy.getTitle();
|
||||
DEMO.info("no exception -- this line should be unreachable");
|
||||
} catch (RuntimeException e) {
|
||||
DEMO.info("accessing the proxy after close threw {}: {}", e.getClass().getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void step6_proxyVsRealIdentity(Long id) {
|
||||
DEMO.info("--- Step 6: proxy identity vs a real loaded instance ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
Book real = session.get(Book.class, id);
|
||||
// second em/session so this is a genuinely separate proxy, not the same cached instance
|
||||
EntityManager em2 = emf.createEntityManager();
|
||||
em2.getTransaction().begin();
|
||||
Session session2 = em2.unwrap(Session.class);
|
||||
Book proxy = session2.getReference(Book.class, id);
|
||||
|
||||
DEMO.info("real.getClass() = {}", real.getClass().getName());
|
||||
DEMO.info("proxy.getClass() = {}", proxy.getClass().getName());
|
||||
DEMO.info("proxy instanceof Book.class: {}", Book.class.isInstance(proxy));
|
||||
DEMO.info("real.getClass() == proxy.getClass(): {}", real.getClass() == proxy.getClass());
|
||||
DEMO.info("real.equals(proxy) before proxy access: {}", real.equals(proxy));
|
||||
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
em2.getTransaction().commit();
|
||||
em2.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ankurm.hibernatedemo.scenario;
|
||||
|
||||
import com.ankurm.hibernatedemo.model.WidgetIdentity;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Backs ankurm.com post 4861 ("inserting objects efficiently") and
|
||||
* docs/03-inserting-objects.md. Captured verbatim into docs/output/insert-identity.txt by
|
||||
* {@code scripts/run.sh insert-identity}.
|
||||
*
|
||||
* <p>Same {@code hibernate.jdbc.batch_size} and {@code hibernate.order_inserts} settings as
|
||||
* {@link InsertSequenceRunner} -- the only difference is {@link WidgetIdentity}'s
|
||||
* {@code GenerationType.IDENTITY} strategy. Compare the two captured output files directly;
|
||||
* the diff between them is the entire point of this pair.
|
||||
*/
|
||||
@Component
|
||||
@Profile("insert-identity")
|
||||
public class InsertIdentityRunner implements CommandLineRunner {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
private static final int ROW_COUNT = 30;
|
||||
|
||||
private final EntityManagerFactory emf;
|
||||
|
||||
public InsertIdentityRunner(EntityManagerFactory emf) {
|
||||
this.emf = emf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
stats.clear();
|
||||
|
||||
DEMO.info("--- inserting {} WidgetIdentity rows (GenerationType.IDENTITY) ---", ROW_COUNT);
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
for (int i = 1; i <= ROW_COUNT; i++) {
|
||||
em.persist(new WidgetIdentity("identity-" + i));
|
||||
}
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
|
||||
DEMO.info("entityInsertCount = {}", stats.getEntityInsertCount());
|
||||
DEMO.info("prepareStatementCount = {}", stats.getPrepareStatementCount());
|
||||
DEMO.info("(with IDENTITY, expect prepareStatementCount to land close to entityInsertCount --" +
|
||||
" each insert has to go to the database immediately to hand back the generated key,"
|
||||
+ " so there is nothing left for hibernate.jdbc.batch_size to batch)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.hibernatedemo.scenario;
|
||||
|
||||
import com.ankurm.hibernatedemo.model.WidgetSequence;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Backs ankurm.com post 4861 ("inserting objects efficiently") and
|
||||
* docs/03-inserting-objects.md. Captured verbatim into docs/output/insert-sequence.txt by
|
||||
* {@code scripts/run.sh insert-sequence}.
|
||||
*
|
||||
* <p>Same {@code hibernate.jdbc.batch_size} and {@code hibernate.order_inserts} settings as
|
||||
* {@link InsertIdentityRunner} -- see that class's Javadoc for what this pair is demonstrating.
|
||||
*/
|
||||
@Component
|
||||
@Profile("insert-sequence")
|
||||
public class InsertSequenceRunner implements CommandLineRunner {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
private static final int ROW_COUNT = 30;
|
||||
|
||||
private final EntityManagerFactory emf;
|
||||
|
||||
public InsertSequenceRunner(EntityManagerFactory emf) {
|
||||
this.emf = emf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
stats.clear();
|
||||
|
||||
DEMO.info("--- inserting {} WidgetSequence rows (GenerationType.SEQUENCE, allocationSize=25) ---", ROW_COUNT);
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
for (int i = 1; i <= ROW_COUNT; i++) {
|
||||
em.persist(new WidgetSequence("sequence-" + i));
|
||||
}
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
|
||||
DEMO.info("entityInsertCount = {}", stats.getEntityInsertCount());
|
||||
DEMO.info("prepareStatementCount = {}", stats.getPrepareStatementCount());
|
||||
DEMO.info("(with SEQUENCE, the id is known before the row is written, so Hibernate can" +
|
||||
" defer and batch the inserts -- expect prepareStatementCount well below entityInsertCount)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.ankurm.hibernatedemo.scenario;
|
||||
|
||||
import com.ankurm.hibernatedemo.model.Book;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import jakarta.persistence.OptimisticLockException;
|
||||
import org.hibernate.Session;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Backs ankurm.com post 4860 ("merge() vs refresh()") and docs/02-merge-vs-refresh.md.
|
||||
* Captured verbatim into docs/output/merge-vs-refresh.txt by
|
||||
* {@code scripts/run.sh mergerefresh}.
|
||||
*
|
||||
* <p>{@link Book} carries a {@code @Version} column specifically so this scenario can show what
|
||||
* {@code merge()} does when the detached instance it is given is holding a version older than
|
||||
* what is currently in the database — not just what it does to an un-versioned row.
|
||||
*/
|
||||
@Component
|
||||
@Profile("mergerefresh")
|
||||
public class MergeVsRefreshRunner implements CommandLineRunner {
|
||||
|
||||
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
|
||||
|
||||
private final EntityManagerFactory emf;
|
||||
|
||||
public MergeVsRefreshRunner(EntityManagerFactory emf) {
|
||||
this.emf = emf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Long id = seedOneBook();
|
||||
Book detached = loadThenDetach(id);
|
||||
simulateAnotherProcessEditingTheRow(id);
|
||||
mergeStaleDetachedInstance(detached);
|
||||
refreshSilentlyDiscardsUnflushedEdit(id);
|
||||
}
|
||||
|
||||
private Long seedOneBook() {
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Book book = new Book("Clean Code", "Robert C. Martin");
|
||||
em.persist(book);
|
||||
em.getTransaction().commit();
|
||||
Long id = book.getId();
|
||||
em.close();
|
||||
DEMO.info("SEED: inserted {}", book);
|
||||
return id;
|
||||
}
|
||||
|
||||
private Book loadThenDetach(Long id) {
|
||||
DEMO.info("--- Step 1: load the row, then close the session (entity is now detached) ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
Book book = session.get(Book.class, id);
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
DEMO.info("detached instance in hand: {}", book);
|
||||
return book;
|
||||
}
|
||||
|
||||
private void simulateAnotherProcessEditingTheRow(Long id) {
|
||||
DEMO.info("--- Step 2: a second, independent session edits the same row and commits ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
Book book = session.get(Book.class, id);
|
||||
book.setTitle("Clean Code (2nd Edition)");
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
DEMO.info("second session committed: {} -- version column has now advanced in the database", book);
|
||||
}
|
||||
|
||||
private void mergeStaleDetachedInstance(Book detached) {
|
||||
DEMO.info("--- Step 3: mutate the ORIGINAL detached instance (still holding the OLD version) and merge() it ---");
|
||||
detached.setAuthor("Robert C. Martin (Uncle Bob)");
|
||||
DEMO.info("detached instance before merge (note the version and title are both stale): {}", detached);
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
try {
|
||||
Book merged = session.merge(detached);
|
||||
em.getTransaction().commit();
|
||||
DEMO.info("merge() succeeded, returned managed instance: {}", merged);
|
||||
} catch (OptimisticLockException e) {
|
||||
em.getTransaction().rollback();
|
||||
DEMO.info("merge() threw {}: {}", e.getClass().getName(), e.getMessage());
|
||||
DEMO.info("the title change from Step 2 survives untouched -- merge() refused to apply a write built on a stale version");
|
||||
} finally {
|
||||
em.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshSilentlyDiscardsUnflushedEdit(Long id) {
|
||||
DEMO.info("--- Step 4: refresh() on a MANAGED entity with an unflushed local edit ---");
|
||||
EntityManager em = emf.createEntityManager();
|
||||
em.getTransaction().begin();
|
||||
Session session = em.unwrap(Session.class);
|
||||
Book book = session.get(Book.class, id);
|
||||
book.setAuthor("SOMEONE ELSE ENTIRELY (never flushed)");
|
||||
DEMO.info("before refresh(): {}", book);
|
||||
session.refresh(book);
|
||||
DEMO.info("after refresh(): {} -- the local edit is gone, no exception was thrown", book);
|
||||
em.getTransaction().commit();
|
||||
em.close();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+18
@@ -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>
|
||||
Executable
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+83
@@ -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
Reference in New Issue
Block a user