commit 453fad0ee72eb1d08f228e9870f5e6f1bd7a1ee1 Author: asmhatre Date: Sat Nov 14 01:29:32 2026 +0530 Hibernate 8 + Jakarta Persistence 4: EntityAgent and stateless sessions demo Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b7jUQrypvpE1ix1k7V diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec10551 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +.idea/ +*.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6096b58 --- /dev/null +++ b/README.md @@ -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`. diff --git a/output/01-entitymanager-vs-entityagent.txt b/output/01-entitymanager-vs-entityagent.txt new file mode 100644 index 0000000..8cd1763 --- /dev/null +++ b/output/01-entitymanager-vs-entityagent.txt @@ -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() diff --git a/output/02-get-find-and-no-cache.txt b/output/02-get-find-and-no-cache.txt new file mode 100644 index 0000000..27ebf82 --- /dev/null +++ b/output/02-get-find-and-no-cache.txt @@ -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 (?,?,?) diff --git a/output/03-lazy-associations-and-fetch.txt b/output/03-lazy-associations-and-fetch.txt new file mode 100644 index 0000000..3660b35 --- /dev/null +++ b/output/03-lazy-associations-and-fetch.txt @@ -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=? diff --git a/output/04-bulk-insert.txt b/output/04-bulk-insert.txt new file mode 100644 index 0000000..0900d2c --- /dev/null +++ b/output/04-bulk-insert.txt @@ -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) diff --git a/output/05-lock-modes-in-entityagent.txt b/output/05-lock-modes-in-entityagent.txt new file mode 100644 index 0000000..bef1495 --- /dev/null +++ b/output/05-lock-modes-in-entityagent.txt @@ -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 + diff --git a/output/06-excluded-from-versioning.txt b/output/06-excluded-from-versioning.txt new file mode 100644 index 0000000..b0423e0 --- /dev/null +++ b/output/06-excluded-from-versioning.txt @@ -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 diff --git a/output/07-programmatic-result-set-mapping.txt b/output/07-programmatic-result-set-mapping.txt new file mode 100644 index 0000000..0870ea8 --- /dev/null +++ b/output/07-programmatic-result-set-mapping.txt @@ -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) diff --git a/output/08-upsert-delete-and-errors.txt b/output/08-upsert-delete-and-errors.txt new file mode 100644 index 0000000..902d2bb --- /dev/null +++ b/output/08-upsert-delete-and-errors.txt @@ -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 diff --git a/output/09-same-operations-two-databases.txt b/output/09-same-operations-two-databases.txt new file mode 100644 index 0000000..5ec9e0e --- /dev/null +++ b/output/09-same-operations-two-databases.txt @@ -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 + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..880d4bd --- /dev/null +++ b/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + com.ankurm + hibernate8-jpa4-demo + 1.0.0 + jar + Hibernate 8 + Jakarta Persistence 4: EntityAgent demo + + + 25 + UTF-8 + 8.0.0.CR1 + 2.5.252 + 6.1.3 + + + + + org.hibernate.orm + hibernate-core + ${hibernate.version} + + + com.h2database + h2 + ${h2.version} + + + org.slf4j + slf4j-simple + 2.0.17 + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.hsqldb + hsqldb + 2.7.4 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + + diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..5089a96 --- /dev/null +++ b/scripts/run-all.sh @@ -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 diff --git a/src/main/java/com/ankurm/agent/Book.java b/src/main/java/com/ankurm/agent/Book.java new file mode 100644 index 0000000..3a8669a --- /dev/null +++ b/src/main/java/com/ankurm/agent/Book.java @@ -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); + } +} diff --git a/src/main/java/com/ankurm/agent/BookSummary.java b/src/main/java/com/ankurm/agent/BookSummary.java new file mode 100644 index 0000000..bafa1fd --- /dev/null +++ b/src/main/java/com/ankurm/agent/BookSummary.java @@ -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) { +} diff --git a/src/main/java/com/ankurm/agent/Publisher.java b/src/main/java/com/ankurm/agent/Publisher.java new file mode 100644 index 0000000..7b7d8f1 --- /dev/null +++ b/src/main/java/com/ankurm/agent/Publisher.java @@ -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 books = new ArrayList<>(); + + public Publisher() { + } + + public Publisher(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/ankurm/agent/Setting.java b/src/main/java/com/ankurm/agent/Setting.java new file mode 100644 index 0000000..95f4225 --- /dev/null +++ b/src/main/java/com/ankurm/agent/Setting.java @@ -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; + } +} diff --git a/src/test/java/com/ankurm/agent/Demo01Test.java b/src/test/java/com/ankurm/agent/Demo01Test.java new file mode 100644 index 0000000..fba577b --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo01Test.java @@ -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); + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo02Test.java b/src/test/java/com/ankurm/agent/Demo02Test.java new file mode 100644 index 0000000..7de0cec --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo02Test.java @@ -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 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()); + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo03Test.java b/src/test/java/com/ankurm/agent/Demo03Test.java new file mode 100644 index 0000000..22acad8 --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo03Test.java @@ -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)); + }); + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo04Test.java b/src/test/java/com/ankurm/agent/Demo04Test.java new file mode 100644 index 0000000..6869bcd --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo04Test.java @@ -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 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); + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo05Test.java b/src/test/java/com/ankurm/agent/Demo05Test.java new file mode 100644 index 0000000..b485dab --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo05Test.java @@ -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 afterGet = new ArrayList<>(); + List 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"); + } + } + } + } + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo06Test.java b/src/test/java/com/ankurm/agent/Demo06Test.java new file mode 100644 index 0000000..99d369d --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo06Test.java @@ -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); + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo07Test.java b/src/test/java/com/ankurm/agent/Demo07Test.java new file mode 100644 index 0000000..7bb6600 --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo07Test.java @@ -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 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 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 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 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 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()); + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo08Test.java b/src/test/java/com/ankurm/agent/Demo08Test.java new file mode 100644 index 0000000..287a7fd --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo08Test.java @@ -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); + } + } +} diff --git a/src/test/java/com/ankurm/agent/Demo09Test.java b/src/test/java/com/ankurm/agent/Demo09Test.java new file mode 100644 index 0000000..fe2ba82 --- /dev/null +++ b/src/test/java/com/ankurm/agent/Demo09Test.java @@ -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); + } + } + } + } +} diff --git a/src/test/java/com/ankurm/agent/Lab.java b/src/test/java/com/ankurm/agent/Lab.java new file mode 100644 index 0000000..e9c046f --- /dev/null +++ b/src/test/java/com/ankurm/agent/Lab.java @@ -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 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 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(); + } +} diff --git a/src/test/java/com/ankurm/agent/Transcript.java b/src/test/java/com/ankurm/agent/Transcript.java new file mode 100644 index 0000000..8a523fd --- /dev/null +++ b/src/test/java/com/ankurm/agent/Transcript.java @@ -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 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 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); + } + } +}