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:
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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 (?,?,?)
|
||||||
@@ -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=?
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
Executable
+7
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user