Hibernate 8 + Jakarta Persistence 4: EntityAgent and stateless sessions demo

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7jUQrypvpE1ix1k7V
This commit is contained in:
asmhatre
2026-11-14 01:34:34 +05:30
co-authored by Claude Sonnet 5.5
commit 453fad0ee7
29 changed files with 1109 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
target/
.idea/
*.iml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+47
View File
@@ -0,0 +1,47 @@
# Hibernate 8 + Jakarta Persistence 4: EntityAgent demo
Runnable companion to the ankurm.com article on `EntityAgent` and stateless sessions.
Every number in the article comes from a file in [`output/`](output), and each of those files is
written by a test that asserts the same numbers, so a figure that stops being true fails the build.
## Versions tested
| Component | Version |
|---|---|
| Hibernate ORM | 8.0.0.CR1 (release candidate, 12 Nov 2026) |
| Jakarta Persistence API | 4.0.0-M8 (milestone, resolved transitively by CR1) |
| JDK | Temurin 25.0.5+8 |
| H2 | 2.5.252 |
| HSQLDB (test scope, comparison runs) | 2.7.4 |
| JUnit | 6.1.3 |
| Maven | 3.9.11 |
Hibernate 8 and Jakarta Persistence 4 are pre-release. Assertions in `Demo05Test` and `Demo09Test`
are deliberately pinned to CR1 behaviour, so they fail when a later build changes it.
## Run it
./scripts/run-all.sh
## Layout
| Path | What it shows |
|---|---|
| `src/main/java/com/ankurm/agent/` | `Book`, `Publisher`, `Setting` entities and the `BookSummary` record |
| `src/test/java/com/ankurm/agent/Lab.java` | boots a SessionFactory and records every prepared SQL statement |
| `Demo01Test` | same edit through `EntityManager` and `EntityAgent` |
| `Demo02Test` | `get()` vs `find()`, no first-level cache, `getMultiple()` |
| `Demo03Test` | lazy associations and `fetch()` |
| `Demo04Test` | bulk insert: persistence-context size and statements prepared |
| `Demo05Test` | lock modes on a stateless handler, H2 and HSQLDB |
| `Demo06Test` | `@ExcludedFromVersioning` |
| `Demo07Test` | programmatic result set mappings |
| `Demo08Test` | `upsert()`, `delete()`, duplicate-key exception |
| `Demo09Test` | the same operations on H2 and HSQLDB |
## Captured output
`output/01-entitymanager-vs-entityagent.txt`, `02-get-find-and-no-cache`,
`03-lazy-associations-and-fetch`, `04-bulk-insert`, `05-lock-modes-in-entityagent`,
`06-excluded-from-versioning`, `07-programmatic-result-set-mapping`,
`08-upsert-delete-and-errors`, `09-same-operations-two-databases`.
@@ -0,0 +1,13 @@
EntityManager: find, edit title, commit (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
select version from final table (update book set price=?,publisher_id=?,title=?,version=?,views=? where isbn=? and version=?)
title in database afterwards: Changed via EntityManager
EntityAgent: get, edit title, commit (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
title in database afterwards: Book 2
EntityAgent: get, edit title, update(book), commit (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
select version from final table (update book set version=case when price is distinct from ? or publisher_id is distinct from ? or title is distinct from ? then ? else version end,price=?,publisher_id=?,title=?,views=? where isbn=? and version=?)
title in database afterwards: Changed via EntityAgent + update()
+17
View File
@@ -0,0 +1,17 @@
agent.find(missing) -> null
agent.get(missing) -> EntityNotFoundException
EntityAgent, same id twice in one transaction:
same instance? false
SQL (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
EntityManager, same id twice in one transaction:
same instance? true
SQL (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
agent.getMultiple(3 ids) returned 3 books
SQL (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn in (?,?,?)
+12
View File
@@ -0,0 +1,12 @@
agent.get(Book): publisher initialized? false
book.publisher.getName() before fetch -> LazyInitializationException
agent.fetch(book.publisher) -> getName() = Manning
same object as book.publisher? true, initialized now? true
reading the public field p.name instead -> null
SQL (1 statement)
select p1_0.id,p1_0.name from publisher p1_0 where p1_0.id=?
agent.get(Publisher): books collection initialized? false
agent.fetch(publisher.books) -> 3 books
SQL (1 statement)
select b1_0.publisher_id,b1_0.isbn,b1_0.price,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.publisher_id=?
+13
View File
@@ -0,0 +1,13 @@
EntityManager.persist x 5000 in one transaction
entities held in the persistence context at the end: 5000
entity inserts: 5000 statements prepared: 1
EntityAgent.insert x 5000 in one transaction
entities held anywhere: 0 (a StatelessSession has no persistence context)
entity inserts: 5000 statements prepared: 5000
EntityAgent.insertMultiple(list of 5000)
entity inserts: 5000 statements prepared: 1
publisher.id before insert: null
publisher.id after insert: 1 (insert() itself returns void)
+45
View File
@@ -0,0 +1,45 @@
=== H2 ===
get(Book.class, "978-1", OPTIMISTIC)
right after get() (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
during commit (0 statements)
version before: 0, after: 0
get(Book.class, "978-1", OPTIMISTIC_FORCE_INCREMENT)
right after get() (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
update book set version=? where isbn=? and version=?
during commit (0 statements)
version before: 0, after: 1
get(Book.class, "978-1", PESSIMISTIC_WRITE)
right after get() (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
during commit (0 statements)
version before: 1, after: 1
get(Book.class, "978-1", PESSIMISTIC_FORCE_INCREMENT)
right after get() (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
update book set version=? where isbn=? and version=?
during commit (0 statements)
version before: 1, after: 2
=== HSQLDB ===
get(Book.class, "978-1", OPTIMISTIC)
FAILED: org.hibernate.HibernateException: Stateless optimistic locking requires transaction-long row protection; no strategy established for HSQLDialect:2:LOCKING
get(Book.class, "978-1", OPTIMISTIC_FORCE_INCREMENT)
FAILED: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "owner" is null
get(Book.class, "978-1", PESSIMISTIC_WRITE)
right after get() (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
during commit (0 statements)
version before: 0, after: 0
get(Book.class, "978-1", PESSIMISTIC_FORCE_INCREMENT)
FAILED: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "owner" is null
+3
View File
@@ -0,0 +1,3 @@
loaded: version=0 views=0
after update() that changed only views: version=0 views=42
after update() that changed title: version=1 views=42
@@ -0,0 +1,5 @@
entity mapping through EntityAgent: 3 rows, first = 978-1 / Book 1 / 10.00
entity mapping through EntityManager: 3 rows, managed by the persistence context? true
constructor mapping -> BookSummary[title=Book 1, price=10.00]
tuple mapping -> (978-1, 0)
+11
View File
@@ -0,0 +1,11 @@
createEntityAgent() returns: org.hibernate.internal.StatelessSessionImpl
instanceof org.hibernate.StatelessSession: true
upsert(theme=dark) on an existing row, upsert(language=en) on a new row (2 statements)
merge into setting as t using (select cast(? as varchar(255)) name,cast(? as varchar(255)) val) as s on (t.name=s.name) when not matched then insert (name,val) values (s.name,s.val) when matched then update set val=s.val
merge into setting as t using (select cast(? as varchar(255)) name,cast(? as varchar(255)) val) as s on (t.name=s.name) when not matched then insert (name,val) values (s.name,s.val) when matched then update set val=s.val
theme -> dark
language -> en
insert() with an existing primary key -> jakarta.persistence.EntityExistsException
find() after delete() -> null
@@ -0,0 +1,12 @@
=== H2 ===
EntityAgent.update(stale book) -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
EntityManager.merge(stale book) -> jakarta.persistence.OptimisticLockException
EntityAgent.update(row not in db) -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
EntityAgent.upsert(new versioned) -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
=== HSQLDB ===
EntityAgent.update(stale book) -> jakarta.persistence.OptimisticLockException
EntityManager.merge(stale book) -> jakarta.persistence.OptimisticLockException
EntityAgent.update(row not in db) -> jakarta.persistence.OptimisticLockException
EntityAgent.upsert(new versioned) -> jakarta.persistence.OptimisticLockException
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm</groupId>
<artifactId>hibernate8-jpa4-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Hibernate 8 + Jakarta Persistence 4: EntityAgent demo</name>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hibernate.version>8.0.0.CR1</hibernate.version>
<h2.version>2.5.252</h2.version>
<junit.version>6.1.3</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<version>2.7.4</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>
</project>
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Regenerates every file in output/ by running the test suite. Needs JDK 25 and Maven.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B test
echo "Transcripts written to output/:"
ls output
+50
View File
@@ -0,0 +1,50 @@
package com.ankurm.agent;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.ExcludedFromVersioning;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
/**
* The one entity most demos use. Assigned String id (so upsert is easy to show), an optimistic
* {@code @Version}, a lazy to-one association, and a JPA 4 {@code @ExcludedFromVersioning} counter.
*/
@Entity
@Table(name = "book")
public class Book {
@Id
@Column(name = "isbn")
public String isbn;
@Column(name = "title")
public String title;
@Column(name = "price")
public BigDecimal price;
@Version
public int version;
/** Changing only this field must not bump {@link #version}. */
@ExcludedFromVersioning
public int views;
@ManyToOne(fetch = FetchType.LAZY)
public Publisher publisher;
public Book() {
}
public Book(String isbn, String title, String price) {
this.isbn = isbn;
this.title = title;
this.price = new BigDecimal(price);
}
}
@@ -0,0 +1,7 @@
package com.ankurm.agent;
import java.math.BigDecimal;
/** Target of the programmatic constructor result set mapping. */
public record BookSummary(String title, BigDecimal price) {
}
@@ -0,0 +1,36 @@
package com.ankurm.agent;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
/** A publisher with a lazy collection of books. Used to show what an EntityAgent does NOT load. */
@Entity
@Table(name = "publisher")
public class Publisher {
@Id
@GeneratedValue
public Long id;
public String name;
public String getName() {
return name;
}
@OneToMany(mappedBy = "publisher")
public List<Book> books = new ArrayList<>();
public Publisher() {
}
public Publisher(String name) {
this.name = name;
}
}
@@ -0,0 +1,24 @@
package com.ankurm.agent;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** A deliberately unversioned entity: the fair test case for upsert(). */
@Entity
@Table(name = "setting")
public class Setting {
@Id
public String name;
public String val;
public Setting() {
}
public Setting(String name, String val) {
this.name = name;
this.val = val;
}
}
@@ -0,0 +1,56 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** EntityManager tracks changes; EntityAgent does not. Same code, different outcome. */
class Demo01Test {
@Test
void sameEditTwoHandlers() {
try (Lab lab = new Lab(); Transcript t = new Transcript("01-entitymanager-vs-entityagent")) {
lab.seed();
// 1. EntityManager: load, change a field, commit. Nobody calls update().
lab.sf.runInTransaction(em -> {
Book b = em.find(Book.class, "978-1");
b.title = "Changed via EntityManager";
});
t.sql("EntityManager: find, edit title, commit", lab.sql());
assertEquals(2, lab.sql().size(), "select + automatic dirty-check update");
assertTrue(lab.sql().get(1).contains("update book"));
String afterEm = lab.sf.callInTransaction(EntityAgent.class, a -> a.get(Book.class, "978-1")).title;
t.line("title in database afterwards: %s", afterEm);
lab.resetSql();
// 2. EntityAgent: the same three steps.
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-2");
b.title = "Changed via EntityAgent";
});
t.line("");
t.sql("EntityAgent: get, edit title, commit", lab.sql());
assertEquals(1, lab.sql().size(), "select only: nothing noticed the edit");
String afterAgent = lab.sf.callInTransaction(EntityAgent.class, a -> a.get(Book.class, "978-2")).title;
t.line("title in database afterwards: %s", afterAgent);
assertEquals("Book 2", afterAgent, "the edit was silently lost");
lab.resetSql();
// 3. EntityAgent with the explicit update() call.
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-2");
b.title = "Changed via EntityAgent + update()";
a.update(b);
});
t.line("");
t.sql("EntityAgent: get, edit title, update(book), commit", lab.sql());
assertEquals(2, lab.sql().size());
String afterUpdate = lab.sf.callInTransaction(EntityAgent.class, a -> a.get(Book.class, "978-2")).title;
t.line("title in database afterwards: %s", afterUpdate);
assertEquals("Changed via EntityAgent + update()", afterUpdate);
}
}
}
@@ -0,0 +1,66 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import jakarta.persistence.EntityNotFoundException;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** get() vs find(), and the missing first-level cache. */
class Demo02Test {
@Test
void getFindAndNoCache() {
try (Lab lab = new Lab(); Transcript t = new Transcript("02-get-find-and-no-cache")) {
lab.seed();
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book missing = a.find(Book.class, "no-such-isbn");
t.line("agent.find(missing) -> %s", missing);
assertNull(missing);
EntityNotFoundException e = assertThrows(EntityNotFoundException.class,
() -> a.get(Book.class, "no-such-isbn"));
t.line("agent.get(missing) -> %s", e.getClass().getSimpleName());
});
lab.resetSql();
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book first = a.get(Book.class, "978-1");
Book second = a.get(Book.class, "978-1");
t.line("");
t.line("EntityAgent, same id twice in one transaction:");
t.line(" same instance? %s", first == second);
assertNotSame(first, second);
});
t.sql(" SQL", lab.sql());
assertEquals(2, lab.sql().size(), "no first-level cache: two selects");
lab.resetSql();
lab.sf.runInTransaction(em -> {
Book first = em.find(Book.class, "978-1");
Book second = em.find(Book.class, "978-1");
t.line("");
t.line("EntityManager, same id twice in one transaction:");
t.line(" same instance? %s", first == second);
assertSame(first, second);
});
t.sql(" SQL", lab.sql());
assertEquals(1, lab.sql().size(), "persistence context answers the second find");
lab.resetSql();
List<Book> books = lab.sf.callInTransaction(EntityAgent.class,
a -> a.getMultiple(Book.class, List.of("978-1", "978-2", "978-3")));
t.line("");
t.line("agent.getMultiple(3 ids) returned %d books", books.size());
t.sql(" SQL", lab.sql());
assertEquals(3, books.size());
}
}
}
@@ -0,0 +1,61 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import org.hibernate.Hibernate;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Lazy associations are not loaded for you; fetch() loads them on request. */
class Demo03Test {
@Test
void lazyAssociationsNeedFetch() {
try (Lab lab = new Lab(); Transcript t = new Transcript("03-lazy-associations-and-fetch")) {
lab.seed();
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-1");
boolean init = Hibernate.isInitialized(b.publisher);
t.line("agent.get(Book): publisher initialized? %s", init);
assertFalse(init);
Object viaGetter;
try {
viaGetter = b.publisher.getName();
} catch (RuntimeException e) {
viaGetter = e.getClass().getSimpleName();
}
t.line("book.publisher.getName() before fetch -> %s", viaGetter);
lab.resetSql();
Publisher p = a.fetch(b.publisher);
t.line("agent.fetch(book.publisher) -> getName() = %s", p.getName());
t.line(" same object as book.publisher? %s, initialized now? %s",
p == b.publisher, Hibernate.isInitialized(p));
t.line(" reading the public field p.name instead -> %s", p.name);
t.sql(" SQL", lab.sql());
assertEquals("Manning", p.getName());
assertEquals(1, lab.sql().size());
});
lab.sf.runInTransaction(EntityAgent.class, a -> {
Publisher p = a.get(Publisher.class, lab.sf.callInTransaction(em ->
em.createQuery("select p.id from Publisher p", Long.class).getSingleResult()));
boolean init = Hibernate.isInitialized(p.books);
t.line("");
t.line("agent.get(Publisher): books collection initialized? %s", init);
assertFalse(init);
lab.resetSql();
var books = a.fetch(p.books);
t.line("agent.fetch(publisher.books) -> %d books", books.size());
t.sql(" SQL", lab.sql());
assertEquals(3, books.size());
assertTrue(Hibernate.isInitialized(books));
});
}
}
}
@@ -0,0 +1,74 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/** Bulk insert: what the persistence context costs, and what insert() returns. */
class Demo04Test {
private static final int N = 5_000;
@Test
void bulkInsert() {
try (Lab lab = new Lab(50); Transcript t = new Transcript("04-bulk-insert")) {
var stats = lab.sf.getStatistics();
// EntityManager, naive loop with no flush()/clear().
stats.clear();
int[] managed = new int[1];
lab.sf.runInTransaction(em -> {
for (int i = 0; i < N; i++) {
em.persist(new Book("em-" + i, "Title " + i, "9.99"));
}
managed[0] = em.unwrap(Session.class).getStatistics().getEntityCount();
});
t.line("EntityManager.persist x %d in one transaction", N);
t.line(" entities held in the persistence context at the end: %d", managed[0]);
t.line(" entity inserts: %d statements prepared: %d",
stats.getEntityInsertCount(), stats.getPrepareStatementCount());
assertEquals(N, managed[0]);
// EntityAgent, one insert() per row.
stats.clear();
lab.sf.runInTransaction(EntityAgent.class, a -> {
for (int i = 0; i < N; i++) {
a.insert(new Book("ag-" + i, "Title " + i, "9.99"));
}
});
t.line("");
t.line("EntityAgent.insert x %d in one transaction", N);
t.line(" entities held anywhere: 0 (a StatelessSession has no persistence context)");
t.line(" entity inserts: %d statements prepared: %d",
stats.getEntityInsertCount(), stats.getPrepareStatementCount());
assertEquals(N, stats.getEntityInsertCount());
// EntityAgent, insertMultiple().
stats.clear();
List<Book> batch = new ArrayList<>();
for (int i = 0; i < N; i++) {
batch.add(new Book("mu-" + i, "Title " + i, "9.99"));
}
lab.sf.runInTransaction(EntityAgent.class, a -> a.insertMultiple(batch));
t.line("");
t.line("EntityAgent.insertMultiple(list of %d)", N);
t.line(" entity inserts: %d statements prepared: %d",
stats.getEntityInsertCount(), stats.getPrepareStatementCount());
assertEquals(N, stats.getEntityInsertCount());
// What insert() hands back: nothing. The generated id lands on the entity instead.
Publisher p = new Publisher("Generated id demo");
t.line("");
t.line("publisher.id before insert: %s", p.id);
lab.sf.runInTransaction(EntityAgent.class, a -> a.insert(p));
t.line("publisher.id after insert: %s (insert() itself returns void)", p.id);
assertNotNull(p.id);
}
}
}
@@ -0,0 +1,87 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import jakarta.persistence.LockModeType;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Lock modes on a stateless handler: when does the lock (or version check) actually reach the database? */
class Demo05Test {
private static int version(Lab lab, String isbn) {
int v = lab.sf.callInTransaction(EntityAgent.class, a -> a.get(Book.class, isbn)).version;
lab.resetSql();
return v;
}
@Test
void lockModesInStatelessHandler() {
try (Transcript t = new Transcript("05-lock-modes-in-entityagent")) {
for (Lab.Db db : Lab.Db.values()) {
try (Lab lab = new Lab(db)) {
lab.seed();
t.line("=== %s ===", db);
t.line("");
for (LockModeType mode : List.of(LockModeType.OPTIMISTIC, LockModeType.OPTIMISTIC_FORCE_INCREMENT,
LockModeType.PESSIMISTIC_WRITE, LockModeType.PESSIMISTIC_FORCE_INCREMENT)) {
int before = version(lab, "978-1");
List<String> afterGet = new ArrayList<>();
List<String> afterCommit = new ArrayList<>();
String failure = null;
try (EntityAgent a = lab.sf.createEntityAgent()) {
var tx = a.getTransaction();
tx.begin();
try {
a.get(Book.class, "978-1", mode);
afterGet.addAll(lab.sql());
lab.resetSql();
tx.commit();
afterCommit.addAll(lab.sql());
} catch (RuntimeException e) {
failure = e.getClass().getName() + ": " + e.getMessage();
if (tx.isActive()) {
tx.rollback();
}
}
}
lab.resetSql();
int after = version(lab, "978-1");
t.line("get(Book.class, \"978-1\", %s)", mode);
if (failure != null) {
t.line(" FAILED: %s", failure);
t.line("");
if (db == Lab.Db.HSQLDB && mode == LockModeType.OPTIMISTIC) {
assertTrue(failure.contains("Stateless optimistic locking requires transaction-long row protection"));
}
continue;
}
t.sql(" right after get()", afterGet);
t.sql(" during commit", afterCommit);
t.line(" version before: %d, after: %d", before, after);
t.line("");
switch (mode) {
case OPTIMISTIC, PESSIMISTIC_WRITE -> assertEquals(before, after);
case OPTIMISTIC_FORCE_INCREMENT, PESSIMISTIC_FORCE_INCREMENT ->
assertEquals(before + 1, after);
default -> { }
}
assertEquals(0, afterCommit.size(), "nothing is deferred to commit");
if (db == Lab.Db.H2 && mode == LockModeType.OPTIMISTIC) {
assertTrue(afterGet.get(0).endsWith("for update"),
"pinned to CR1: OPTIMISTIC reads with a row lock on H2");
}
}
}
}
}
}
}
@@ -0,0 +1,37 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** @ExcludedFromVersioning: a field that may change without bumping the optimistic version. */
class Demo06Test {
@Test
void excludedFromVersioning() {
try (Lab lab = new Lab(); Transcript t = new Transcript("06-excluded-from-versioning")) {
lab.seed();
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-1");
t.line("loaded: version=%d views=%d", b.version, b.views);
b.views = 42;
a.update(b);
});
Book afterViews = lab.sf.callInTransaction(EntityAgent.class, a -> a.get(Book.class, "978-1"));
t.line("after update() that changed only views: version=%d views=%d", afterViews.version, afterViews.views);
assertEquals(0, afterViews.version);
assertEquals(42, afterViews.views);
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-1");
b.title = "Retitled";
a.update(b);
});
Book afterTitle = lab.sf.callInTransaction(EntityAgent.class, a -> a.get(Book.class, "978-1"));
t.line("after update() that changed title: version=%d views=%d", afterTitle.version, afterTitle.views);
assertEquals(1, afterTitle.version);
}
}
}
@@ -0,0 +1,66 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import jakarta.persistence.Tuple;
import jakarta.persistence.sql.ResultSetMapping;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static jakarta.persistence.sql.ResultSetMapping.column;
import static jakarta.persistence.sql.ResultSetMapping.constructor;
import static jakarta.persistence.sql.ResultSetMapping.entity;
import static jakarta.persistence.sql.ResultSetMapping.field;
import static jakarta.persistence.sql.ResultSetMapping.tuple;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Programmatic result set mappings: entity, constructor and tuple, with no @SqlResultSetMapping. */
class Demo07Test {
@Test
void programmaticResultSetMappings() {
try (Lab lab = new Lab(); Transcript t = new Transcript("07-programmatic-result-set-mapping")) {
lab.seed();
String sql = "select isbn, title as book_title, price, version, views, publisher_id from book order by isbn";
ResultSetMapping<Book> asEntity = entity(Book.class,
field(Book.class, String.class, "isbn", "isbn"),
field(Book.class, String.class, "title", "book_title"),
field(Book.class, BigDecimal.class, "price", "price"),
field(Book.class, Integer.class, "version", "version"),
field(Book.class, Integer.class, "views", "views"));
List<Book> viaAgent = lab.sf.callInTransaction(EntityAgent.class,
a -> a.createNativeQuery(sql, asEntity).getResultList());
t.line("entity mapping through EntityAgent: %d rows, first = %s / %s / %s",
viaAgent.size(), viaAgent.get(0).isbn, viaAgent.get(0).title, viaAgent.get(0).price);
assertEquals(3, viaAgent.size());
lab.sf.runInTransaction(em -> {
List<Book> viaEm = em.createNativeQuery(sql, asEntity).getResultList();
t.line("entity mapping through EntityManager: %d rows, managed by the persistence context? %s",
viaEm.size(), em.contains(viaEm.get(0)));
});
// A constructor mapping needs no entity at all.
List<BookSummary> summaries = lab.sf.callInTransaction(EntityAgent.class,
a -> a.createNativeQuery("select title, price from book order by isbn",
constructor(BookSummary.class,
column("title", String.class),
column("price", BigDecimal.class))).getResultList());
t.line("");
t.line("constructor mapping -> %s", summaries.get(0));
assertEquals("Book 1", summaries.get(0).title());
// A tuple mapping, read back by column position.
List<Tuple> tuples = lab.sf.callInTransaction(EntityAgent.class,
a -> a.createNativeQuery("select isbn, views from book order by isbn",
tuple(column("isbn", String.class), column("views", Integer.class))).getResultList());
Tuple first = tuples.get(0);
t.line("tuple mapping -> (%s, %s)", first.get(0, String.class), first.get(1, Integer.class));
assertFalse(tuples.isEmpty());
}
}
}
@@ -0,0 +1,57 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import jakarta.persistence.EntityExistsException;
import org.hibernate.StatelessSession;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** upsert on an unversioned entity, delete, the duplicate-key exception, and what an EntityAgent really is. */
class Demo08Test {
@Test
void upsertDeleteAndErrors() {
try (Lab lab = new Lab(); Transcript t = new Transcript("08-upsert-delete-and-errors")) {
lab.seed();
try (EntityAgent a = lab.sf.createEntityAgent()) {
t.line("createEntityAgent() returns: %s", a.getClass().getName());
t.line("instanceof org.hibernate.StatelessSession: %s", a instanceof StatelessSession);
assertTrue(a instanceof StatelessSession);
}
lab.sf.runInTransaction(EntityAgent.class, a -> a.insert(new Setting("theme", "light")));
lab.resetSql();
lab.sf.runInTransaction(EntityAgent.class, a -> {
a.upsert(new Setting("theme", "dark")); // row exists
a.upsert(new Setting("language", "en")); // row does not exist
});
t.line("");
t.sql("upsert(theme=dark) on an existing row, upsert(language=en) on a new row", lab.sql());
lab.sf.runInTransaction(EntityAgent.class, a -> {
t.line("theme -> %s", a.get(Setting.class, "theme").val);
t.line("language -> %s", a.get(Setting.class, "language").val);
assertEquals("dark", a.get(Setting.class, "theme").val);
assertEquals("en", a.get(Setting.class, "language").val);
});
t.line("");
EntityExistsException dup = assertThrows(EntityExistsException.class,
() -> lab.sf.runInTransaction(EntityAgent.class,
a -> a.insert(new Book("978-2", "Duplicate", "1.00"))));
t.line("insert() with an existing primary key -> %s", dup.getClass().getName());
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-3");
a.delete(b);
});
Book gone = lab.sf.callInTransaction(EntityAgent.class, a -> a.find(Book.class, "978-3"));
t.line("find() after delete() -> %s", gone);
assertNull(gone);
}
}
}
@@ -0,0 +1,83 @@
package com.ankurm.agent;
import jakarta.persistence.EntityAgent;
import jakarta.persistence.OptimisticLockException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Same stateless operations, two databases. These assertions are deliberately PINNED to
* Hibernate 8.0.0.CR1: if a later build changes what H2 throws, this test fails and the blog post
* needs correcting.
*/
class Demo09Test {
private static String attempt(Runnable r) {
try {
r.run();
return "no exception";
} catch (RuntimeException e) {
// Only a bare HibernateException needs its message to be understood.
return e.getClass() == org.hibernate.HibernateException.class
? e.getClass().getName() + ": " + e.getMessage()
: e.getClass().getName();
}
}
@Test
void sameOperationsTwoDatabases() {
try (Transcript t = new Transcript("09-same-operations-two-databases")) {
for (Lab.Db db : Lab.Db.values()) {
try (Lab lab = new Lab(db)) {
lab.seed();
t.line("=== %s ===", db);
// A stale update(): another handler changes the row after we read it.
Book stale = lab.sf.callInTransaction(EntityAgent.class, a -> a.get(Book.class, "978-1"));
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book winner = a.get(Book.class, "978-1");
winner.title = "Winner";
a.update(winner);
});
stale.title = "Loser";
String agentStale = attempt(() ->
lab.sf.runInTransaction(EntityAgent.class, a -> a.update(stale)));
t.line("EntityAgent.update(stale book) -> %s", agentStale);
// The same conflict through the stateful API.
Book stale2 = lab.sf.callInTransaction(em -> em.find(Book.class, "978-2"));
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book winner = a.get(Book.class, "978-2");
winner.title = "Winner";
a.update(winner);
});
String emStale = attempt(() -> lab.sf.runInTransaction(em -> {
Book managed = em.merge(stale2);
managed.title = "Loser";
}));
t.line("EntityManager.merge(stale book) -> %s", emStale);
String ghost = attempt(() -> lab.sf.runInTransaction(EntityAgent.class,
a -> a.update(new Book("no-such-isbn", "Ghost", "1.00"))));
t.line("EntityAgent.update(row not in db) -> %s", ghost);
String upsertNew = attempt(() -> lab.sf.runInTransaction(EntityAgent.class,
a -> a.upsert(new Book("978-9", "Brand new", "5.00"))));
t.line("EntityAgent.upsert(new versioned) -> %s", upsertNew);
t.line("");
assertEquals(OptimisticLockException.class.getName(), emStale);
assertNotNull(agentStale);
switch (db) {
case H2 -> assertEquals("org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book", agentStale);
case HSQLDB -> assertEquals(OptimisticLockException.class.getName(), agentStale);
}
assertInstanceOf(String.class, ghost);
}
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package com.ankurm.agent;
import org.hibernate.SessionFactory;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/**
* Boots a Hibernate 8 SessionFactory on an in-memory database and records every SQL statement
* Hibernate prepares, so each demo can print exactly what reached the database.
*/
final class Lab implements AutoCloseable {
enum Db {
H2("jdbc:h2:mem:lab-%s;DB_CLOSE_DELAY=-1"),
HSQLDB("jdbc:hsqldb:mem:lab%s");
final String urlPattern;
Db(String urlPattern) {
this.urlPattern = urlPattern;
}
String url() {
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
return urlPattern.formatted(id);
}
}
final SessionFactory sf;
final Db db;
private final List<String> sql = Collections.synchronizedList(new ArrayList<>());
Lab() {
this(Db.H2, 50);
}
Lab(int jdbcBatchSize) {
this(Db.H2, jdbcBatchSize);
}
Lab(Db db) {
this(db, 50);
}
Lab(Db db, int jdbcBatchSize) {
this.db = db;
this.sf = new HibernatePersistenceConfiguration("lab")
.managedClasses(Book.class, Publisher.class, Setting.class)
.jdbcUrl(db.url())
.jdbcCredentials("sa", "")
.property("jakarta.persistence.schema-generation.database.action", "create")
.property("hibernate.jdbc.batch_size", String.valueOf(jdbcBatchSize))
.collectStatistics(true)
.statementInspector(s -> {
sql.add(s);
return s;
})
.createEntityManagerFactory();
}
/** Statements prepared since the last {@link #resetSql()}. */
List<String> sql() {
synchronized (sql) {
return List.copyOf(sql);
}
}
void resetSql() {
sql.clear();
}
/** Inserts one publisher and three books, then forgets the SQL this produced. */
Publisher seed() {
Publisher p = new Publisher("Manning");
sf.runInTransaction(em -> {
em.persist(p);
for (int i = 1; i <= 3; i++) {
Book b = new Book("978-" + i, "Book " + i, "10.00");
b.publisher = p;
em.persist(b);
}
});
resetSql();
return p;
}
@Override
public void close() {
sf.close();
}
}
@@ -0,0 +1,43 @@
package com.ankurm.agent;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Collects printed lines and writes them to output/NN-name.txt. Every figure in the blog post comes
* from one of these files, and the same test asserts the same figure, so a number that stops being
* true fails the build instead of silently going stale.
*/
final class Transcript implements AutoCloseable {
private final Path file;
private final List<String> lines = new ArrayList<>();
Transcript(String name) {
this.file = Path.of("output", name + ".txt");
}
void line(String fmt, Object... args) {
lines.add(args.length == 0 ? fmt : String.format(fmt, args));
}
void sql(String label, List<String> statements) {
line("%s (%d statement%s)", label, statements.size(), statements.size() == 1 ? "" : "s");
for (String s : statements) {
line(" %s", s);
}
}
@Override
public void close() {
try {
Files.createDirectories(file.getParent());
Files.write(file, lines);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}