diff --git a/README.md b/README.md
index e2f8df7..fda5326 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,8 @@ One module per article. This repository currently holds:
| Module | Article | Status |
|---|---|---|
| [`collections`](collections/) | Top 40 Java Collections Interview Questions (HashMap Internals to Fail-Fast Iterators) | published |
-| [`lld-parking-lot`](lld-parking-lot/) | Low-Level Design Interview in Java: Parking Lot System, Step by Step | this commit |
+| [`lld-parking-lot`](lld-parking-lot/) | Low-Level Design Interview in Java: Parking Lot System, Step by Step | published |
+| [`hibernate-jpa`](hibernate-jpa/) | Top 40 Hibernate and JPA Interview Questions (2026) with Runnable Answers | this commit |
## `collections` module
diff --git a/hibernate-jpa/.gitignore b/hibernate-jpa/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/hibernate-jpa/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/hibernate-jpa/README.md b/hibernate-jpa/README.md
new file mode 100644
index 0000000..4250fbe
--- /dev/null
+++ b/hibernate-jpa/README.md
@@ -0,0 +1,19 @@
+# hibernate-jpa
+
+Companion code for **Top 40 Hibernate and JPA Interview Questions (2026) with Runnable Answers** on ankurm.com.
+
+One test per question (`src/test/java/lab/hibernate/A_...F_*`). Each test writes its own transcript to `output/qNN-*.txt` and asserts the same facts, so an answer in the article that stops being true fails the build.
+
+| Component | Version |
+|---|---|
+| Hibernate ORM | 7.4.13.Final |
+| PostgreSQL | 16.15 (server), pgjdbc 42.7.14 |
+| JUnit | 6.1.3 |
+| JDK | 25.0.5 |
+
+```bash
+./scripts/start-postgres.sh # throwaway PostgreSQL on :5433 (or point LAB_DB_URL at your own)
+./scripts/run-all.sh # runs 40 tests, rewrites output/
+```
+
+The test JVM runs with `-Duser.timezone=UTC` (see `pom.xml`): a PostgreSQL build without a complete tz database rejects some JVM default zones at connect time.
diff --git a/hibernate-jpa/output/q01-jpa-vs-hibernate.txt b/hibernate-jpa/output/q01-jpa-vs-hibernate.txt
new file mode 100644
index 0000000..b3d34fb
--- /dev/null
+++ b/hibernate-jpa/output/q01-jpa-vs-hibernate.txt
@@ -0,0 +1,4 @@
+Session is an EntityManager : true
+SessionFactory is an EMF : true
+em.unwrap(Session.class) == em : true
+Hibernate-only API reachable : s.getStatistics().isStatisticsEnabled() = true
diff --git a/hibernate-jpa/output/q02-entity-states.txt b/hibernate-jpa/output/q02-entity-states.txt
new file mode 100644
index 0000000..fbf01ce
--- /dev/null
+++ b/hibernate-jpa/output/q02-entity-states.txt
@@ -0,0 +1,5 @@
+new Employee() : managed=false id=null
+after persist() : managed=true id assigned=true
+after detach() : managed=false
+after merge() : original managed=false, returned copy managed=true, same object=false
+after remove() : managed=false
diff --git a/hibernate-jpa/output/q03-persist-vs-merge.txt b/hibernate-jpa/output/q03-persist-vs-merge.txt
new file mode 100644
index 0000000..1447479
--- /dev/null
+++ b/hibernate-jpa/output/q03-persist-vs-merge.txt
@@ -0,0 +1,3 @@
+persist(detached) -> jakarta.persistence.EntityExistsException: Detached entity passed to persist: lab.hibernate.model.Employee
+merge(detached) -> returns a different instance: true
+salary changed on the original after merge is stored: false (db has 100)
diff --git a/hibernate-jpa/output/q04-save-update-removed.txt b/hibernate-jpa/output/q04-save-update-removed.txt
new file mode 100644
index 0000000..afd4a38
--- /dev/null
+++ b/hibernate-jpa/output/q04-save-update-removed.txt
@@ -0,0 +1,2 @@
+methods present on org.hibernate.Session : [find, get, getReference, load, merge, persist, refresh, remove]
+methods NOT present : [delete, save, saveOrUpdate, update]
diff --git a/hibernate-jpa/output/q05-find-vs-getreference.txt b/hibernate-jpa/output/q05-find-vs-getreference.txt
new file mode 100644
index 0000000..0359d29
--- /dev/null
+++ b/hibernate-jpa/output/q05-find-vs-getreference.txt
@@ -0,0 +1,7 @@
+getReference(): SQL statements so far = 0, initialized = false
+ref.name (field) -> null; SQL statements now = 0, initialized = false
+ref.getName() -> Chen; SQL statements now = 1, initialized = true
+SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
+find(missing id) -> null
+getReference(missing) -> proxy returned without error: true
+first access -> org.hibernate.ObjectNotFoundException: No row with the given identifier exists for entity [lab.hibernate.model.Employee with id '99999']
diff --git a/hibernate-jpa/output/q06-dirty-checking-flush.txt b/hibernate-jpa/output/q06-dirty-checking-flush.txt
new file mode 100644
index 0000000..1faad53
--- /dev/null
+++ b/hibernate-jpa/output/q06-dirty-checking-flush.txt
@@ -0,0 +1,8 @@
+change a managed entity, no save call:
+SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
+SQL> update Employee set city=?,zip=?,department_id=?,level=?,name=?,salary=? where id=?
+count visible inside the transaction (AUTO flush before the query): 2
+persist then query:
+SQL> select nextval('Employee_SEQ')
+SQL> insert into Employee (city,zip,department_id,level,name,salary,id) values (?,?,?,?,?,?,?)
+SQL> select count(e1_0.id) from Employee e1_0
diff --git a/hibernate-jpa/output/q07-merge-vs-refresh.txt b/hibernate-jpa/output/q07-merge-vs-refresh.txt
new file mode 100644
index 0000000..979e966
--- /dev/null
+++ b/hibernate-jpa/output/q07-merge-vs-refresh.txt
@@ -0,0 +1,2 @@
+refresh(): pending in-memory change thrown away, salary = 100
+merge(detached with salary 500) -> database now has 500
diff --git a/hibernate-jpa/output/q08-first-level-cache.txt b/hibernate-jpa/output/q08-first-level-cache.txt
new file mode 100644
index 0000000..38748fb
--- /dev/null
+++ b/hibernate-jpa/output/q08-first-level-cache.txt
@@ -0,0 +1,5 @@
+two find() calls, same session: SQL selects = 1, same instance = true
+after clear(): SQL selects = 2, same instance as before = false
+SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
+SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
+a different session: SQL selects = 1 (the first-level cache is per session)
diff --git a/hibernate-jpa/output/q09-no-arg-constructor.txt b/hibernate-jpa/output/q09-no-arg-constructor.txt
new file mode 100644
index 0000000..ac51e11
--- /dev/null
+++ b/hibernate-jpa/output/q09-no-arg-constructor.txt
@@ -0,0 +1,4 @@
+SessionFactory started with an entity that has no no-arg constructor: true
+persist(): ok
+find() -> org.hibernate.InstantiationException: No default constructor for entity 'lab.hibernate.model.BadEntity'
+root cause -> org.hibernate.InstantiationException: No default constructor for entity 'lab.hibernate.model.BadEntity'
diff --git a/hibernate-jpa/output/q10-identity-vs-sequence-batching.txt b/hibernate-jpa/output/q10-identity-vs-sequence-batching.txt
new file mode 100644
index 0000000..398f746
--- /dev/null
+++ b/hibernate-jpa/output/q10-identity-vs-sequence-batching.txt
@@ -0,0 +1,2 @@
+SEQUENCE: 25 entities -> 1 prepared insert statement(s) with jdbc.batch_size=10, 2 sequence selects
+IDENTITY: 25 entities -> 25 prepared insert statements (one per row)
diff --git a/hibernate-jpa/output/q11-embeddable-columns.txt b/hibernate-jpa/output/q11-embeddable-columns.txt
new file mode 100644
index 0000000..3741b9e
--- /dev/null
+++ b/hibernate-jpa/output/q11-embeddable-columns.txt
@@ -0,0 +1 @@
+columns of table employee: [city, department_id, id, level, name, salary, zip]
diff --git a/hibernate-jpa/output/q12-enum-ordinal-vs-string.txt b/hibernate-jpa/output/q12-enum-ordinal-vs-string.txt
new file mode 100644
index 0000000..e72b500
--- /dev/null
+++ b/hibernate-jpa/output/q12-enum-ordinal-vs-string.txt
@@ -0,0 +1,2 @@
+@Enumerated(STRING) stores LEAD as: LEAD
+@Enumerated (default) stores LEAD as: 2 (its position in the enum: [JUNIOR, SENIOR, LEAD])
diff --git a/hibernate-jpa/output/q13-element-collection-table.txt b/hibernate-jpa/output/q13-element-collection-table.txt
new file mode 100644
index 0000000..a08cd6e
--- /dev/null
+++ b/hibernate-jpa/output/q13-element-collection-table.txt
@@ -0,0 +1 @@
+tables: [comment, post, post_tags]
diff --git a/hibernate-jpa/output/q14-inheritance-strategies.txt b/hibernate-jpa/output/q14-inheritance-strategies.txt
new file mode 100644
index 0000000..9c16cc0
--- /dev/null
+++ b/hibernate-jpa/output/q14-inheritance-strategies.txt
@@ -0,0 +1,5 @@
+tables: [bankpayment, cardpayment, payment, shape]
+JOINED 'from Payment' returned 2 rows
+SINGLE_TABLE 'from Shape' returned 2 rows
+SQL> select p1_0.id,case when p1_2.id is not null then 1 when p1_1.id is not null then 2 end,p1_0.amount,p1_1.iban,p1_2.last4 from Payment p1_0 left join BankPayment p1_1 on p1_0.id=p1_1.id left join CardPayment p1_2 on p1_0.id=p1_2.id
+SQL> select s1_0.id,s1_0.kind,s1_0.label,s1_0.radius,s1_0.side from Shape s1_0
diff --git a/hibernate-jpa/output/q15-mapped-superclass.txt b/hibernate-jpa/output/q15-mapped-superclass.txt
new file mode 100644
index 0000000..2499c50
--- /dev/null
+++ b/hibernate-jpa/output/q15-mapped-superclass.txt
@@ -0,0 +1,3 @@
+tables: [note]
+select from the entity Note: 1 row(s), created_by column inherited = system
+select from the @MappedSuperclass -> org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'Auditable'
diff --git a/hibernate-jpa/output/q16-equals-hashcode-generated-id.txt b/hibernate-jpa/output/q16-equals-hashcode-generated-id.txt
new file mode 100644
index 0000000..40b4d4e
--- /dev/null
+++ b/hibernate-jpa/output/q16-equals-hashcode-generated-id.txt
@@ -0,0 +1,2 @@
+before persist: id=null, set.contains(item)=true
+after persist : id assigned=true, hashCode changed, set.contains(item)=false
diff --git a/hibernate-jpa/output/q17-default-fetch-types.txt b/hibernate-jpa/output/q17-default-fetch-types.txt
new file mode 100644
index 0000000..1fc376f
--- /dev/null
+++ b/hibernate-jpa/output/q17-default-fetch-types.txt
@@ -0,0 +1,7 @@
+default of fetch() on each JPA annotation, read from the API jar:
+ @Basic -> EAGER
+ @ManyToOne -> EAGER
+ @OneToOne -> EAGER
+ @OneToMany -> LAZY
+ @ManyToMany -> LAZY
+ @ElementCollection -> LAZY
diff --git a/hibernate-jpa/output/q18-owning-side.txt b/hibernate-jpa/output/q18-owning-side.txt
new file mode 100644
index 0000000..55cd94e
--- /dev/null
+++ b/hibernate-jpa/output/q18-owning-side.txt
@@ -0,0 +1,2 @@
+employee.department_id after d.employees.add(e) only: null
+after setting employee.department too : 1
diff --git a/hibernate-jpa/output/q19-n-plus-one.txt b/hibernate-jpa/output/q19-n-plus-one.txt
new file mode 100644
index 0000000..563e2d0
--- /dev/null
+++ b/hibernate-jpa/output/q19-n-plus-one.txt
@@ -0,0 +1,7 @@
+naive loop over 3 departments: 4 SQL statements (1 + 3)
+SQL> select d1_0.id,d1_0.name from Department d1_0
+SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
+SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
+SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
+with join fetch: 1 SQL statement
+SQL> select distinct d1_0.id,e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary,d1_0.name from Department d1_0 join Employee e1_0 on d1_0.id=e1_0.department_id
diff --git a/hibernate-jpa/output/q20-join-fetch-vs-entity-graph.txt b/hibernate-jpa/output/q20-join-fetch-vs-entity-graph.txt
new file mode 100644
index 0000000..5f853fe
--- /dev/null
+++ b/hibernate-jpa/output/q20-join-fetch-vs-entity-graph.txt
@@ -0,0 +1,4 @@
+join fetch : department initialized = true
+SQL> select e1_0.id,e1_0.city,e1_0.zip,d1_0.id,d1_0.name,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 join Department d1_0 on d1_0.id=e1_0.department_id
+entity graph : department initialized = true
+SQL> select e1_0.id,e1_0.city,e1_0.zip,d1_0.id,d1_0.name,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 left join Department d1_0 on d1_0.id=e1_0.department_id
diff --git a/hibernate-jpa/output/q21-batch-fetch-size.txt b/hibernate-jpa/output/q21-batch-fetch-size.txt
new file mode 100644
index 0000000..e2b777a
--- /dev/null
+++ b/hibernate-jpa/output/q21-batch-fetch-size.txt
@@ -0,0 +1,4 @@
+default_batch_fetch_size=2, 3 departments: 3 SQL statements (1 + ceil(3/2))
+SQL> select d1_0.id,d1_0.name from Department d1_0
+SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id = any (?)
+SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
diff --git a/hibernate-jpa/output/q22-lazy-initialization-exception.txt b/hibernate-jpa/output/q22-lazy-initialization-exception.txt
new file mode 100644
index 0000000..8607117
--- /dev/null
+++ b/hibernate-jpa/output/q22-lazy-initialization-exception.txt
@@ -0,0 +1,3 @@
+read a lazy collection after the session closed -> org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'lab.hibernate.model.Department.employees' with key '1' (no session)
+cure 1: fetch it inside the query -> 3 employees, 1 SQL statement
+cure 2: ask for exactly the columns you need -> [E11, E12, E13], 1 SQL statement
diff --git a/hibernate-jpa/output/q23-multiple-bag-fetch.txt b/hibernate-jpa/output/q23-multiple-bag-fetch.txt
new file mode 100644
index 0000000..2b96700
--- /dev/null
+++ b/hibernate-jpa/output/q23-multiple-bag-fetch.txt
@@ -0,0 +1,2 @@
+two List collections in one join fetch -> java.lang.IllegalArgumentException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [lab.hibernate.model.Post.comments, lab.hibernate.model.Post.tags]
+fix: one collection per query -> comments=2 tags=2, 2 SQL statements
diff --git a/hibernate-jpa/output/q24-pagination-join-fetch.txt b/hibernate-jpa/output/q24-pagination-join-fetch.txt
new file mode 100644
index 0000000..3b862ed
--- /dev/null
+++ b/hibernate-jpa/output/q24-pagination-join-fetch.txt
@@ -0,0 +1,6 @@
+join fetch of a collection + setMaxResults(2) -> 2 departments
+SQL contains a row limit: true
+SQL> select d1_0.id,e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary,d1_0.name from (select d1_0.id,d1_0.name from Department d1_0 where exists(select 1 from Employee e1_0 where d1_0.id=e1_0.department_id) order by d1_0.id offset ? rows fetch first ? rows only) d1_0(id,name) join Employee e1_0 on d1_0.id=e1_0.department_id order by d1_0.id
+two-step fix -> 2 departments
+SQL> select d1_0.id from Department d1_0 order by d1_0.id fetch first ? rows only
+SQL> select distinct d1_0.id,e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary,d1_0.name from Department d1_0 join Employee e1_0 on d1_0.id=e1_0.department_id where d1_0.id in (?,?) order by d1_0.id
diff --git a/hibernate-jpa/output/q25-cascade-transient.txt b/hibernate-jpa/output/q25-cascade-transient.txt
new file mode 100644
index 0000000..3f0a728
--- /dev/null
+++ b/hibernate-jpa/output/q25-cascade-transient.txt
@@ -0,0 +1,2 @@
+persist Employee that points at an unsaved Department -> java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: Persistent instance of 'lab.hibernate.model.Employee' references an unsaved transient instance of 'lab.hibernate.model.Department' (persist the transient instance before flushing) [lab.hibernate.model.Employee.department -> lab.hibernate.model.Department]
+Post -> comments has CascadeType.ALL: comments stored = 1
diff --git a/hibernate-jpa/output/q26-orphan-removal.txt b/hibernate-jpa/output/q26-orphan-removal.txt
new file mode 100644
index 0000000..e287d2d
--- /dev/null
+++ b/hibernate-jpa/output/q26-orphan-removal.txt
@@ -0,0 +1,9 @@
+removed a line from the collection only:
+SQL> select po1_0.id,po1_0.customer from PurchaseOrder po1_0 where po1_0.id=?
+SQL> select l1_0.order_id,l1_0.id,l1_0.sku from OrderLine l1_0 where l1_0.order_id=?
+SQL> delete from OrderLine where id=?
+removed the order (cascade ALL):
+SQL> select po1_0.id,po1_0.customer from PurchaseOrder po1_0 where po1_0.id=?
+SQL> select l1_0.order_id,l1_0.id,l1_0.sku from OrderLine l1_0 where l1_0.order_id=?
+SQL> delete from OrderLine where id=?
+SQL> delete from PurchaseOrder where id=?
diff --git a/hibernate-jpa/output/q27-eager-many-to-one.txt b/hibernate-jpa/output/q27-eager-many-to-one.txt
new file mode 100644
index 0000000..dfe26a9
--- /dev/null
+++ b/hibernate-jpa/output/q27-eager-many-to-one.txt
@@ -0,0 +1,6 @@
+'from EagerEmployee' with the default @ManyToOne: 4 SQL statements for 9 employees in 3 departments
+SQL> select ee1_0.id,ee1_0.department_id,ee1_0.name from EagerEmployee ee1_0
+SQL> select d1_0.id,d1_0.name from Department d1_0 where d1_0.id=?
+SQL> select d1_0.id,d1_0.name from Department d1_0 where d1_0.id=?
+SQL> select d1_0.id,d1_0.name from Department d1_0 where d1_0.id=?
+'from Employee' with @ManyToOne(fetch = LAZY): 1 SQL statement
diff --git a/hibernate-jpa/output/q28-second-level-cache.txt b/hibernate-jpa/output/q28-second-level-cache.txt
new file mode 100644
index 0000000..7fd44d5
--- /dev/null
+++ b/hibernate-jpa/output/q28-second-level-cache.txt
@@ -0,0 +1,6 @@
+right after persist() the row is already in L2 (READ_WRITE caches on insert): SQL selects = 0, hits = 1
+after evictAllRegions(), three find() calls, three different sessions:
+ SQL selects : 1
+ L2 cache hits : 2
+ L2 cache misses : 1
+ L2 cache puts : 1
diff --git a/hibernate-jpa/output/q29-query-cache.txt b/hibernate-jpa/output/q29-query-cache.txt
new file mode 100644
index 0000000..01776b5
--- /dev/null
+++ b/hibernate-jpa/output/q29-query-cache.txt
@@ -0,0 +1,5 @@
+run 1: query cache hits=0 misses=1 puts=1
+run 2: query cache hits=1 misses=1 puts=1
+SQL selects for Product after 2 runs: 1
+run 3 after inserting a Product: query cache hits=1 misses=2
+SQL selects for Product after 3 runs: 3
diff --git a/hibernate-jpa/output/q30-bulk-update-vs-l2.txt b/hibernate-jpa/output/q30-bulk-update-vs-l2.txt
new file mode 100644
index 0000000..9a8eddb
--- /dev/null
+++ b/hibernate-jpa/output/q30-bulk-update-vs-l2.txt
@@ -0,0 +1,3 @@
+after an HQL bulk update to 11 : find() sees 11 (L2 hits=0 misses=1)
+after a native bulk update to 12 : find() sees 12 (L2 hits=0 misses=1)
+after raw JDBC update to 13 : find() sees 12 (L2 hits=1 misses=0) <- stale: Hibernate was not told
diff --git a/hibernate-jpa/output/q31-optimistic-locking.txt b/hibernate-jpa/output/q31-optimistic-locking.txt
new file mode 100644
index 0000000..360f485
--- /dev/null
+++ b/hibernate-jpa/output/q31-optimistic-locking.txt
@@ -0,0 +1,6 @@
+both sessions read balance=100 version=0
+session A commits +50 -> version is now 1
+session B commits +25 -> jakarta.persistence.OptimisticLockException: Unexpected row count (expected row count 1 but was 0) [update Account set balance=?,owner=?,version=? where id=? and version=?] for entity [lab.hibernate.model.Account with id '1']
+ cause: org.hibernate.StaleObjectStateException: Unexpected row count (expected row count 1 but was 0) [update Account set balance=?,owner=?,version=? where id=? and version=?] for entity [lab.hibernate.model.Account with id '1']
+SQL> update Account set balance=?,owner=?,version=? where id=? and version=?
+final balance: 150 (B's stale write was rejected, A's +50 kept)
diff --git a/hibernate-jpa/output/q32-pessimistic-locking.txt b/hibernate-jpa/output/q32-pessimistic-locking.txt
new file mode 100644
index 0000000..d6be89c
--- /dev/null
+++ b/hibernate-jpa/output/q32-pessimistic-locking.txt
@@ -0,0 +1,3 @@
+session 1: find(..., PESSIMISTIC_WRITE) ran: select a1_0.id,a1_0.balance,a1_0.owner,a1_0.version from Account a1_0 where a1_0.id=? for no key update of a1_0
+session 2 with lock.timeout=0 -> jakarta.persistence.PessimisticLockException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "account"] [select a1_0.id,a1_0.balance,a1_0.owner,a1_0.version from Account a1_0 where a1_0.id=? for no key update of a1_0 nowait]
+ cause: org.hibernate.exception.LockTimeoutException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "account"] [select a1_0.id,a1_0.balance,a1_0.owner,a1_0.version from Account a1_0 where a1_0.id=? for no key update of a1_0 nowait]
diff --git a/hibernate-jpa/output/q33-lost-update.txt b/hibernate-jpa/output/q33-lost-update.txt
new file mode 100644
index 0000000..e349193
--- /dev/null
+++ b/hibernate-jpa/output/q33-lost-update.txt
@@ -0,0 +1,2 @@
+SQL> update PlainAccount set balance=?,owner=? where id=?
+two deposits (+50, +25) on 100 -> final balance 125, expected 175: A's deposit silently lost
diff --git a/hibernate-jpa/output/q34-isolation-levels.txt b/hibernate-jpa/output/q34-isolation-levels.txt
new file mode 100644
index 0000000..a9a2bdb
--- /dev/null
+++ b/hibernate-jpa/output/q34-isolation-levels.txt
@@ -0,0 +1,3 @@
+PostgreSQL default isolation level: read committed
+read committed : first read 100, another transaction commits 150, second read 150 -> non-repeatable read
+repeatable read : first read 100, another transaction commits 150, second read 100 -> repeatable
diff --git a/hibernate-jpa/output/q35-version-bump-collections.txt b/hibernate-jpa/output/q35-version-bump-collections.txt
new file mode 100644
index 0000000..d632baf
--- /dev/null
+++ b/hibernate-jpa/output/q35-version-bump-collections.txt
@@ -0,0 +1,2 @@
+Wallet (owns a unidirectional @OneToMany): add a Coin -> version 0 -> 1
+Folder (inverse, mappedBy @OneToMany) : add a Doc -> version 0 -> 0
diff --git a/hibernate-jpa/output/q36-dynamic-update.txt b/hibernate-jpa/output/q36-dynamic-update.txt
new file mode 100644
index 0000000..af81265
--- /dev/null
+++ b/hibernate-jpa/output/q36-dynamic-update.txt
@@ -0,0 +1,6 @@
+default entity, change one column:
+SQL> select p1_0.id,p1_0.a,p1_0.b,p1_0.c from Plain p1_0 where p1_0.id=?
+SQL> update Plain set a=?,b=?,c=? where id=?
+@DynamicUpdate entity, change one column:
+SQL> select w1_0.id,w1_0.a,w1_0.b,w1_0.c from Wide w1_0 where w1_0.id=?
+SQL> update Wide set a=? where id=?
diff --git a/hibernate-jpa/output/q37-hql-criteria-native.txt b/hibernate-jpa/output/q37-hql-criteria-native.txt
new file mode 100644
index 0000000..87d09e8
--- /dev/null
+++ b/hibernate-jpa/output/q37-hql-criteria-native.txt
@@ -0,0 +1,6 @@
+HQL -> [Cy, Ana]
+Criteria -> [Cy, Ana]
+Native -> [Cy, Ana]
+SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.salary>? order by e1_0.salary
+SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.salary>? order by e1_0.salary
+SQL> select * from employee where salary > 150 order by salary
diff --git a/hibernate-jpa/output/q38-dto-projection.txt b/hibernate-jpa/output/q38-dto-projection.txt
new file mode 100644
index 0000000..fd69511
--- /dev/null
+++ b/hibernate-jpa/output/q38-dto-projection.txt
@@ -0,0 +1,4 @@
+record projection -> [EmployeeRow[name=Bo, salary=100], EmployeeRow[name=Cy, salary=200], EmployeeRow[name=Ana, salary=300]]
+entities loaded: 0
+SQL> select e1_0.name,e1_0.salary from Employee e1_0 order by e1_0.salary
+full entity query loads 3 entities
diff --git a/hibernate-jpa/output/q39-bulk-update-stale-entity.txt b/hibernate-jpa/output/q39-bulk-update-stale-entity.txt
new file mode 100644
index 0000000..18e362e
--- /dev/null
+++ b/hibernate-jpa/output/q39-bulk-update-stale-entity.txt
@@ -0,0 +1,3 @@
+HQL bulk update touched 3 rows
+the entity already loaded still says salary = 100
+after refresh() it says salary = 999
diff --git a/hibernate-jpa/output/q40-named-query-validation.txt b/hibernate-jpa/output/q40-named-query-validation.txt
new file mode 100644
index 0000000..f785ef4
--- /dev/null
+++ b/hibernate-jpa/output/q40-named-query-validation.txt
@@ -0,0 +1,3 @@
+buildSessionFactory() failed with org.hibernate.query.NamedQueryValidationException:
+ Errors in named queries:
+ [1] Error in query named 'BrokenQuery.byTitle': Could not resolve attribute 'titel' of 'lab.hibernate.model.BrokenQuery' [select b from BrokenQuery b where b.titel = :t]
diff --git a/hibernate-jpa/pom.xml b/hibernate-jpa/pom.xml
new file mode 100644
index 0000000..1eacae3
--- /dev/null
+++ b/hibernate-jpa/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+ com.ankurm.interview
+ hibernate-jpa
+ 1.0.0
+
+
+ 25
+ UTF-8
+ 7.4.13.Final
+ 6.1.3
+ 42.7.14
+ 2.0.20
+ jdbc:postgresql://localhost:5433/lab
+
+
+
+
+ org.hibernate.orm
+ hibernate-core
+ ${hibernate.version}
+
+
+ org.hibernate.orm
+ hibernate-jcache
+ ${hibernate.version}
+
+
+ org.ehcache
+ ehcache
+ 3.11.1
+ jakarta
+
+ **
+
+
+
+
+ jakarta.xml.bind
+ jakarta.xml.bind-api
+ 4.0.2
+
+
+ org.glassfish.jaxb
+ jaxb-runtime
+ 4.0.5
+
+
+ org.postgresql
+ postgresql
+ ${postgresql.version}
+
+
+ org.slf4j
+ slf4j-simple
+ ${slf4j.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.6
+
+ -Duser.timezone=UTC
+
+ ${lab.db.url}
+
+
+
+
+
+
+
+
diff --git a/hibernate-jpa/scripts/run-all.sh b/hibernate-jpa/scripts/run-all.sh
new file mode 100755
index 0000000..7b61626
--- /dev/null
+++ b/hibernate-jpa/scripts/run-all.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Regenerates every file in hibernate-jpa/output/ (one per interview question).
+# Requires PostgreSQL on localhost:5433 with a database "lab" (see start-postgres.sh), JDK 25 and Maven.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+unset JAVA_TOOL_OPTIONS
+rm -f output/q*.txt
+mvn -B -q test -Dlab.db.url="${LAB_DB_URL:-jdbc:postgresql://localhost:5433/lab}"
+echo "done: $(ls output | wc -l) files in output/"
diff --git a/hibernate-jpa/scripts/start-postgres.sh b/hibernate-jpa/scripts/start-postgres.sh
new file mode 100755
index 0000000..bf71b7c
--- /dev/null
+++ b/hibernate-jpa/scripts/start-postgres.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Starts a throwaway PostgreSQL on port 5433 with a database called "lab" (needs PostgreSQL binaries installed).
+# Run as a non-root user. Adjust PGBIN for your platform.
+set -euo pipefail
+PGBIN="${PGBIN:-/usr/lib/postgresql/16/bin}"
+DATA="${PGDATA_DIR:-/tmp/pg-lab/data}"
+mkdir -p "$(dirname "$DATA")"
+[ -d "$DATA" ] || "$PGBIN/initdb" -D "$DATA" -A trust -U postgres >/dev/null
+"$PGBIN/pg_ctl" -D "$DATA" -o "-p 5433 -k /tmp" -l "$(dirname "$DATA")/pg.log" start
+sleep 2
+psql -h localhost -p 5433 -U postgres -tc "select 1 from pg_database where datname='lab'" | grep -q 1 \
+ || psql -h localhost -p 5433 -U postgres -c "create database lab"
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/SqlLog.java b/hibernate-jpa/src/main/java/lab/hibernate/SqlLog.java
new file mode 100644
index 0000000..ed02fa8
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/SqlLog.java
@@ -0,0 +1,28 @@
+package lab.hibernate;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.hibernate.resource.jdbc.spi.StatementInspector;
+
+/** Records every SQL string Hibernate hands to JDBC, so tests can print and count them. */
+public class SqlLog implements StatementInspector {
+ public static final List LINES = new CopyOnWriteArrayList<>();
+
+ @Override
+ public String inspect(String sql) {
+ LINES.add(sql);
+ return sql;
+ }
+
+ public static int mark() {
+ return LINES.size();
+ }
+
+ public static List since(int mark) {
+ return List.copyOf(LINES.subList(mark, LINES.size()));
+ }
+
+ public static long countSince(int mark, String prefix) {
+ return since(mark).stream().filter(s -> s.toLowerCase().startsWith(prefix)).count();
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Account.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Account.java
new file mode 100644
index 0000000..9e58a54
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Account.java
@@ -0,0 +1,38 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class Account {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String owner;
+ public long balance;
+
+ @Version
+ public int version;
+
+ protected Account() {}
+
+ public Account(String owner, long balance) {
+ this.owner = owner;
+ this.balance = balance;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getOwner() {
+ return owner;
+ }
+
+ public long getBalance() {
+ return balance;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Address.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Address.java
new file mode 100644
index 0000000..e09c3a3
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Address.java
@@ -0,0 +1,16 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class Address {
+ public String city;
+ public String zip;
+
+ protected Address() {}
+
+ public Address(String city, String zip) {
+ this.city = city;
+ this.zip = zip;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Auditable.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Auditable.java
new file mode 100644
index 0000000..5dcfdba
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Auditable.java
@@ -0,0 +1,13 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.MappedSuperclass;
+
+/** Shared columns, but not an entity: it cannot be queried. */
+@MappedSuperclass
+public abstract class Auditable {
+ public String createdBy = "system";
+
+ public String getCreatedBy() {
+ return createdBy;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/BadEntity.java b/hibernate-jpa/src/main/java/lab/hibernate/model/BadEntity.java
new file mode 100644
index 0000000..423ad24
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/BadEntity.java
@@ -0,0 +1,14 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+/** No no-argument constructor at all. */
+@Entity
+public class BadEntity {
+ @Id
+ public Long id;
+
+ public BadEntity(Long id) {
+ this.id = id;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/BankPayment.java b/hibernate-jpa/src/main/java/lab/hibernate/model/BankPayment.java
new file mode 100644
index 0000000..c58a022
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/BankPayment.java
@@ -0,0 +1,19 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.Entity;
+
+@Entity
+public class BankPayment extends Payment {
+ public String iban;
+
+ protected BankPayment() {}
+
+ public BankPayment(long amount, String iban) {
+ super(amount);
+ this.iban = iban;
+ }
+
+ public String getIban() {
+ return iban;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/BrokenQuery.java b/hibernate-jpa/src/main/java/lab/hibernate/model/BrokenQuery.java
new file mode 100644
index 0000000..e41b504
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/BrokenQuery.java
@@ -0,0 +1,12 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+/** A named query that cannot compile: it should fail when the SessionFactory starts, not when it is first used. */
+@Entity
+@NamedQuery(name = "BrokenQuery.byTitle", query = "select b from BrokenQuery b where b.titel = :t")
+public class BrokenQuery {
+ @Id
+ public Long id;
+ public String title;
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/CardPayment.java b/hibernate-jpa/src/main/java/lab/hibernate/model/CardPayment.java
new file mode 100644
index 0000000..e3a0a8a
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/CardPayment.java
@@ -0,0 +1,19 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.Entity;
+
+@Entity
+public class CardPayment extends Payment {
+ public String last4;
+
+ protected CardPayment() {}
+
+ public CardPayment(long amount, String last4) {
+ super(amount);
+ this.last4 = last4;
+ }
+
+ public String getLast4() {
+ return last4;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Circle.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Circle.java
new file mode 100644
index 0000000..ef633c6
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Circle.java
@@ -0,0 +1,19 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.Entity;
+
+@Entity
+public class Circle extends Shape {
+ public Integer radius;
+
+ protected Circle() {}
+
+ public Circle(String label, int radius) {
+ super(label);
+ this.radius = radius;
+ }
+
+ public Integer getRadius() {
+ return radius;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Coin.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Coin.java
new file mode 100644
index 0000000..c4210c8
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Coin.java
@@ -0,0 +1,25 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class Coin {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public int cents;
+
+ protected Coin() {}
+
+ public Coin(int cents) {
+ this.cents = cents;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public int getCents() {
+ return cents;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Comment.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Comment.java
new file mode 100644
index 0000000..73660b4
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Comment.java
@@ -0,0 +1,33 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class Comment {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String text;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ public Post post;
+
+ protected Comment() {}
+
+ public Comment(String text, Post post) {
+ this.text = text;
+ this.post = post;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public Post getPost() {
+ return post;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Department.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Department.java
new file mode 100644
index 0000000..cf56581
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Department.java
@@ -0,0 +1,35 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+public class Department {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+
+ /** Inverse side: the foreign key lives in Employee.department_id. Lazy by default. */
+ @OneToMany(mappedBy = "department")
+ public List employees = new ArrayList<>();
+
+ protected Department() {}
+
+ public Department(String name) {
+ this.name = name;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public List getEmployees() {
+ return employees;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Doc.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Doc.java
new file mode 100644
index 0000000..1cab5e6
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Doc.java
@@ -0,0 +1,33 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class Doc {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String title;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ public Folder folder;
+
+ protected Doc() {}
+
+ public Doc(String title, Folder folder) {
+ this.title = title;
+ this.folder = folder;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public Folder getFolder() {
+ return folder;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/EagerEmployee.java b/hibernate-jpa/src/main/java/lab/hibernate/model/EagerEmployee.java
new file mode 100644
index 0000000..d388a09
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/EagerEmployee.java
@@ -0,0 +1,34 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+/** Same shape as Employee but with the JPA default for @ManyToOne (EAGER). */
+@Entity
+public class EagerEmployee {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+
+ @ManyToOne
+ public Department department;
+
+ protected EagerEmployee() {}
+
+ public EagerEmployee(String name, Department department) {
+ this.name = name;
+ this.department = department;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Department getDepartment() {
+ return department;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Employee.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Employee.java
new file mode 100644
index 0000000..2ecb438
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Employee.java
@@ -0,0 +1,63 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import java.util.HashSet;
+import java.util.Set;
+
+@Entity
+public class Employee {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+ public int salary;
+
+ /** Owning side of the association, lazy on purpose. */
+ @ManyToOne(fetch = FetchType.LAZY)
+ public Department department;
+
+ @Embedded
+ public Address address;
+
+ @Enumerated(EnumType.STRING)
+ public Level level = Level.JUNIOR;
+
+ @ManyToMany
+ public Set projects = new HashSet<>();
+
+ protected Employee() {}
+
+ public Employee(String name, int salary, Department department) {
+ this.name = name;
+ this.salary = salary;
+ this.department = department;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getSalary() {
+ return salary;
+ }
+
+ public Department getDepartment() {
+ return department;
+ }
+
+ public Address getAddress() {
+ return address;
+ }
+
+ public Level getLevel() {
+ return level;
+ }
+
+ public Set getProjects() {
+ return projects;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Folder.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Folder.java
new file mode 100644
index 0000000..9cabdbb
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Folder.java
@@ -0,0 +1,42 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Versioned parent with an INVERSE (mappedBy) collection: changing the children does not bump the parent. */
+@Entity
+public class Folder {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+
+ @Version
+ public int version;
+
+ @OneToMany(mappedBy = "folder", cascade = CascadeType.ALL)
+ public List docs = new ArrayList<>();
+
+ protected Folder() {}
+
+ public Folder(String name) {
+ this.name = name;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public List getDocs() {
+ return docs;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Invoice.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Invoice.java
new file mode 100644
index 0000000..e912a3a
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Invoice.java
@@ -0,0 +1,26 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+/** IDENTITY: the database assigns the id, so Hibernate must insert immediately. */
+@Entity
+public class Invoice {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ public Long id;
+ public int number;
+
+ protected Invoice() {}
+
+ public Invoice(int number) {
+ this.number = number;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public int getNumber() {
+ return number;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Level.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Level.java
new file mode 100644
index 0000000..b615d76
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Level.java
@@ -0,0 +1,3 @@
+package lab.hibernate.model;
+
+public enum Level { JUNIOR, SENIOR, LEAD }
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Note.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Note.java
new file mode 100644
index 0000000..b1d973c
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Note.java
@@ -0,0 +1,25 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class Note extends Auditable {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String text;
+
+ protected Note() {}
+
+ public Note(String text) {
+ this.text = text;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getText() {
+ return text;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/OrderLine.java b/hibernate-jpa/src/main/java/lab/hibernate/model/OrderLine.java
new file mode 100644
index 0000000..8f34b7d
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/OrderLine.java
@@ -0,0 +1,32 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class OrderLine {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String sku;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ public PurchaseOrder order;
+
+ protected OrderLine() {}
+
+ public OrderLine(String sku) {
+ this.sku = sku;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getSku() {
+ return sku;
+ }
+
+ public PurchaseOrder getOrder() {
+ return order;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Payment.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Payment.java
new file mode 100644
index 0000000..1772297
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Payment.java
@@ -0,0 +1,26 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+@Inheritance(strategy = InheritanceType.JOINED)
+public abstract class Payment {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public long amount;
+
+ protected Payment() {}
+
+ protected Payment(long amount) {
+ this.amount = amount;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public long getAmount() {
+ return amount;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Plain.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Plain.java
new file mode 100644
index 0000000..b29e788
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Plain.java
@@ -0,0 +1,35 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class Plain {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String a = "a";
+ public String b = "b";
+ public String c = "c";
+
+ protected Plain() {}
+
+ public Plain(String a) {
+ this.a = a;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getA() {
+ return a;
+ }
+
+ public String getB() {
+ return b;
+ }
+
+ public String getC() {
+ return c;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/PlainAccount.java b/hibernate-jpa/src/main/java/lab/hibernate/model/PlainAccount.java
new file mode 100644
index 0000000..5df5bb6
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/PlainAccount.java
@@ -0,0 +1,32 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+/** No @Version: the lost-update demo. */
+@Entity
+public class PlainAccount {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String owner;
+ public long balance;
+
+ protected PlainAccount() {}
+
+ public PlainAccount(String owner, long balance) {
+ this.owner = owner;
+ this.balance = balance;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getOwner() {
+ return owner;
+ }
+
+ public long getBalance() {
+ return balance;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Post.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Post.java
new file mode 100644
index 0000000..13f10af
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Post.java
@@ -0,0 +1,42 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Two List collections on one entity: the MultipleBagFetchException setup. */
+@Entity
+public class Post {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String title;
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
+ public List comments = new ArrayList<>();
+
+ @ElementCollection
+ public List tags = new ArrayList<>();
+
+ protected Post() {}
+
+ public Post(String title) {
+ this.title = title;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public List getComments() {
+ return comments;
+ }
+
+ public List getTags() {
+ return tags;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Product.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Product.java
new file mode 100644
index 0000000..a9eb1e0
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Product.java
@@ -0,0 +1,35 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.Cache;
+import org.hibernate.annotations.CacheConcurrencyStrategy;
+
+@Entity
+@Cacheable
+@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
+public class Product {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+ public int price;
+
+ protected Product() {}
+
+ public Product(String name, int price) {
+ this.name = name;
+ this.price = price;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getPrice() {
+ return price;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Project.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Project.java
new file mode 100644
index 0000000..9060165
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Project.java
@@ -0,0 +1,25 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+public class Project {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String title;
+
+ protected Project() {}
+
+ public Project(String title) {
+ this.title = title;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/PurchaseOrder.java b/hibernate-jpa/src/main/java/lab/hibernate/model/PurchaseOrder.java
new file mode 100644
index 0000000..4829221
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/PurchaseOrder.java
@@ -0,0 +1,39 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+public class PurchaseOrder {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String customer;
+
+ @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
+ public List lines = new ArrayList<>();
+
+ protected PurchaseOrder() {}
+
+ public PurchaseOrder(String customer) {
+ this.customer = customer;
+ }
+
+ public void addLine(OrderLine line) {
+ lines.add(line);
+ line.order = this;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getCustomer() {
+ return customer;
+ }
+
+ public List getLines() {
+ return lines;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/RankedEmployee.java b/hibernate-jpa/src/main/java/lab/hibernate/model/RankedEmployee.java
new file mode 100644
index 0000000..28d1987
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/RankedEmployee.java
@@ -0,0 +1,33 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+/** @Enumerated defaults to ORDINAL: the database stores the constant's position. */
+@Entity
+public class RankedEmployee {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+
+ @Enumerated
+ public Level level = Level.SENIOR;
+
+ protected RankedEmployee() {}
+
+ public RankedEmployee(String name) {
+ this.name = name;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Level getLevel() {
+ return level;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Receipt.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Receipt.java
new file mode 100644
index 0000000..bbcc823
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Receipt.java
@@ -0,0 +1,26 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+/** SEQUENCE: ids are handed out in blocks, so inserts can be batched. */
+@Entity
+public class Receipt {
+ @Id
+ @GeneratedValue(strategy = GenerationType.SEQUENCE)
+ public Long id;
+ public int number;
+
+ protected Receipt() {}
+
+ public Receipt(int number) {
+ this.number = number;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public int getNumber() {
+ return number;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/SetItem.java b/hibernate-jpa/src/main/java/lab/hibernate/model/SetItem.java
new file mode 100644
index 0000000..08e4454
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/SetItem.java
@@ -0,0 +1,37 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import java.util.Objects;
+
+/** hashCode built on the generated id: the classic trap. */
+@Entity
+public class SetItem {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+
+ protected SetItem() {}
+
+ public SetItem(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return o instanceof SetItem other && Objects.equals(id, other.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(id);
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Shape.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Shape.java
new file mode 100644
index 0000000..d936c60
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Shape.java
@@ -0,0 +1,27 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+
+@Entity
+@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
+@DiscriminatorColumn(name = "kind")
+public abstract class Shape {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String label;
+
+ protected Shape() {}
+
+ protected Shape(String label) {
+ this.label = label;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Square.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Square.java
new file mode 100644
index 0000000..64c3f6f
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Square.java
@@ -0,0 +1,19 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.Entity;
+
+@Entity
+public class Square extends Shape {
+ public Integer side;
+
+ protected Square() {}
+
+ public Square(String label, int side) {
+ super(label);
+ this.side = side;
+ }
+
+ public Integer getSide() {
+ return side;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Wallet.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Wallet.java
new file mode 100644
index 0000000..c4b2478
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Wallet.java
@@ -0,0 +1,42 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Owns a unidirectional one-to-many: the collection belongs to this entity, so it is versioned with it. */
+@Entity
+public class Wallet {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String owner;
+
+ @Version
+ public int version;
+
+ @OneToMany(cascade = CascadeType.ALL)
+ public List coins = new ArrayList<>();
+
+ protected Wallet() {}
+
+ public Wallet(String owner) {
+ this.owner = owner;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getOwner() {
+ return owner;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public List getCoins() {
+ return coins;
+ }
+}
diff --git a/hibernate-jpa/src/main/java/lab/hibernate/model/Wide.java b/hibernate-jpa/src/main/java/lab/hibernate/model/Wide.java
new file mode 100644
index 0000000..41b9c66
--- /dev/null
+++ b/hibernate-jpa/src/main/java/lab/hibernate/model/Wide.java
@@ -0,0 +1,37 @@
+package lab.hibernate.model;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.DynamicUpdate;
+
+@Entity
+@DynamicUpdate
+public class Wide {
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String a = "a";
+ public String b = "b";
+ public String c = "c";
+
+ protected Wide() {}
+
+ public Wide(String a) {
+ this.a = a;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getA() {
+ return a;
+ }
+
+ public String getB() {
+ return b;
+ }
+
+ public String getC() {
+ return c;
+ }
+}
diff --git a/hibernate-jpa/src/test/java/lab/hibernate/A_FundamentalsTest.java b/hibernate-jpa/src/test/java/lab/hibernate/A_FundamentalsTest.java
new file mode 100644
index 0000000..799cb69
--- /dev/null
+++ b/hibernate-jpa/src/test/java/lab/hibernate/A_FundamentalsTest.java
@@ -0,0 +1,207 @@
+package lab.hibernate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.TreeSet;
+import lab.hibernate.model.*;
+import org.hibernate.Hibernate;
+import org.hibernate.Session;
+import org.hibernate.Transaction;
+import org.junit.jupiter.api.Test;
+
+class A_FundamentalsTest extends Lab {
+
+ @Test
+ void q01_jpaVersusHibernate() {
+ q(1, "jpa-vs-hibernate");
+ boot(Department.class, Employee.class, Project.class);
+ try (Session s = sf.openSession()) {
+ EntityManager em = s;
+ p("Session is an EntityManager : %s", s instanceof EntityManager);
+ p("SessionFactory is an EMF : %s", sf instanceof EntityManagerFactory);
+ p("em.unwrap(Session.class) == em : %s", em.unwrap(Session.class) == em);
+ p("Hibernate-only API reachable : s.getStatistics().isStatisticsEnabled() = %s", s.getSessionFactory().getStatistics().isStatisticsEnabled());
+ assertTrue(s instanceof EntityManager);
+ }
+ }
+
+ @Test
+ void q02_entityStates() {
+ q(2, "entity-states");
+ boot(Department.class, Employee.class, Project.class);
+ Employee e = new Employee("Asha", 100, null);
+ try (Session s = sf.openSession()) {
+ Transaction tx = s.beginTransaction();
+ p("new Employee() : managed=%s id=%s", s.contains(e), e.id);
+ s.persist(e);
+ p("after persist() : managed=%s id assigned=%s", s.contains(e), e.id != null);
+ tx.commit();
+ s.detach(e);
+ p("after detach() : managed=%s", s.contains(e));
+ Transaction tx2 = s.beginTransaction();
+ Employee managed = s.merge(e);
+ p("after merge() : original managed=%s, returned copy managed=%s, same object=%s", s.contains(e), s.contains(managed), e == managed);
+ s.remove(managed);
+ p("after remove() : managed=%s", s.contains(managed));
+ tx2.commit();
+ assertFalse(s.contains(managed));
+ }
+ }
+
+ @Test
+ void q03_persistVersusMerge() {
+ q(3, "persist-vs-merge");
+ boot(Department.class, Employee.class, Project.class);
+ Employee e = new Employee("Ben", 100, null);
+ tx(s -> s.persist(e));
+ // e is now detached (its session is closed)
+ try {
+ tx(s -> s.persist(e));
+ } catch (Exception ex) {
+ p("persist(detached) -> %s", first(ex));
+ }
+ Employee[] copy = new Employee[1];
+ tx(s -> {
+ copy[0] = s.merge(e);
+ e.salary = 777; // change the ORIGINAL after merge
+ p("merge(detached) -> returns a different instance: %s", copy[0] != e);
+ });
+ int salary = txr(s -> s.find(Employee.class, e.id).salary);
+ p("salary changed on the original after merge is stored: %s (db has %d)", salary == 777, salary);
+ assertEquals(100, salary);
+ }
+
+ @Test
+ void q04_saveUpdateRemovedInHibernate7() {
+ q(4, "save-update-removed");
+ Set wanted = Set.of("save", "update", "saveOrUpdate", "delete", "load", "get", "persist", "merge", "remove", "find", "getReference", "refresh");
+ Set present = new TreeSet<>();
+ for (Method m : Session.class.getMethods()) {
+ if (wanted.contains(m.getName())) {
+ present.add(m.getName());
+ }
+ }
+ Set missing = new TreeSet<>(wanted);
+ missing.removeAll(present);
+ p("methods present on org.hibernate.Session : %s", present);
+ p("methods NOT present : %s", missing);
+ assertFalse(present.contains("save"));
+ assertFalse(present.contains("saveOrUpdate"));
+ assertFalse(present.contains("update"));
+ assertTrue(Arrays.stream(Session.class.getMethods()).anyMatch(m -> m.getName().equals("persist")));
+ }
+
+ @Test
+ void q05_findVersusGetReference() {
+ q(5, "find-vs-getreference");
+ boot(Department.class, Employee.class, Project.class);
+ Long id = txr(s -> {
+ Employee e = new Employee("Chen", 100, null);
+ s.persist(e);
+ return e.id;
+ });
+ try (Session s = sf.openSession()) {
+ int m = SqlLog.mark();
+ Employee ref = s.getReference(Employee.class, id);
+ p("getReference(): SQL statements so far = %d, initialized = %s", SqlLog.since(m).size(), Hibernate.isInitialized(ref));
+ String viaField = ref.name; // a public field is read straight off the proxy object
+ p("ref.name (field) -> %s; SQL statements now = %d, initialized = %s", viaField, SqlLog.since(m).size(), Hibernate.isInitialized(ref));
+ String name = ref.getName(); // a method call is intercepted and loads the row
+ p("ref.getName() -> %s; SQL statements now = %d, initialized = %s", name, SqlLog.since(m).size(), Hibernate.isInitialized(ref));
+ sql(m);
+ assertEquals(1, SqlLog.since(m).size());
+ }
+ try (Session s = sf.openSession()) {
+ p("find(missing id) -> %s", s.find(Employee.class, 99999L));
+ Employee ghost = s.getReference(Employee.class, 99999L);
+ p("getReference(missing) -> proxy returned without error: %s", ghost != null);
+ try {
+ ghost.getName();
+ } catch (RuntimeException ex) {
+ p("first access -> %s", first(ex));
+ }
+ }
+ }
+
+ @Test
+ void q06_dirtyCheckingAndFlush() {
+ q(6, "dirty-checking-flush");
+ boot(Department.class, Employee.class, Project.class);
+ Long id = txr(s -> {
+ Employee e = new Employee("Dev", 100, null);
+ s.persist(e);
+ return e.id;
+ });
+ int m = SqlLog.mark();
+ tx(s -> {
+ Employee e = s.find(Employee.class, id);
+ e.salary = 150; // no save(), no update()
+ });
+ p("change a managed entity, no save call:");
+ sql(m);
+ assertEquals(1, SqlLog.countSince(m, "update"));
+ int m2 = SqlLog.mark();
+ tx(s -> {
+ s.persist(new Employee("Eli", 90, null));
+ long n = s.createSelectionQuery("select count(e) from Employee e", Long.class).getSingleResult();
+ p("count visible inside the transaction (AUTO flush before the query): %d", n);
+ });
+ p("persist then query:");
+ sql(m2);
+ }
+
+ @Test
+ void q07_mergeVersusRefresh() {
+ q(7, "merge-vs-refresh");
+ boot(Department.class, Employee.class, Project.class);
+ Employee detached = txr(s -> {
+ Employee e = new Employee("Fay", 100, null);
+ s.persist(e);
+ return e;
+ });
+ detached.salary = 500;
+ tx(s -> {
+ Employee managed = s.find(Employee.class, detached.id);
+ managed.salary = 120; // pending change in memory
+ s.refresh(managed);
+ p("refresh(): pending in-memory change thrown away, salary = %d", managed.salary);
+ assertEquals(100, managed.salary);
+ });
+ tx(s -> s.merge(detached));
+ int now = txr(s -> s.find(Employee.class, detached.id).salary);
+ p("merge(detached with salary 500) -> database now has %d", now);
+ }
+
+ @Test
+ void q08_firstLevelCache() {
+ q(8, "first-level-cache");
+ boot(Department.class, Employee.class, Project.class);
+ Long id = txr(s -> {
+ Employee e = new Employee("Gus", 100, null);
+ s.persist(e);
+ return e.id;
+ });
+ try (Session s = sf.openSession()) {
+ int m = SqlLog.mark();
+ Employee a = s.find(Employee.class, id);
+ Employee b = s.find(Employee.class, id);
+ p("two find() calls, same session: SQL selects = %d, same instance = %s", SqlLog.countSince(m, "select"), a == b);
+ s.clear();
+ Employee c = s.find(Employee.class, id);
+ p("after clear(): SQL selects = %d, same instance as before = %s", SqlLog.countSince(m, "select"), a == c);
+ sql(m);
+ assertTrue(a == b);
+ }
+ try (Session s2 = sf.openSession()) {
+ int m = SqlLog.mark();
+ Employee d = s2.find(Employee.class, id);
+ p("a different session: SQL selects = %d (the first-level cache is per session)", SqlLog.countSince(m, "select"));
+ assertNotNull(d);
+ }
+ }
+}
diff --git a/hibernate-jpa/src/test/java/lab/hibernate/B_MappingTest.java b/hibernate-jpa/src/test/java/lab/hibernate/B_MappingTest.java
new file mode 100644
index 0000000..c0ca53b
--- /dev/null
+++ b/hibernate-jpa/src/test/java/lab/hibernate/B_MappingTest.java
@@ -0,0 +1,148 @@
+package lab.hibernate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import lab.hibernate.model.*;
+import org.hibernate.Session;
+import org.junit.jupiter.api.Test;
+
+class B_MappingTest extends Lab {
+
+ private List tables() {
+ try (Session s = sf.openSession()) {
+ return s.createNativeQuery("select table_name from information_schema.tables where table_schema='public' order by 1", String.class).getResultList();
+ }
+ }
+
+ @Test
+ void q09_entityNeedsNoArgConstructor() {
+ q(9, "no-arg-constructor");
+ boot(BadEntity.class);
+ p("SessionFactory started with an entity that has no no-arg constructor: true");
+ try {
+ tx(s -> s.persist(new BadEntity(1L)));
+ p("persist(): ok");
+ tx(s -> s.find(BadEntity.class, 1L));
+ p("find(): ok");
+ } catch (RuntimeException ex) {
+ p("find() -> %s", first(ex));
+ Throwable root = ex;
+ while (root.getCause() != null) {
+ root = root.getCause();
+ }
+ p("root cause -> %s", first(root));
+ }
+ }
+
+ @Test
+ void q10_identityDisablesBatching() {
+ q(10, "identity-vs-sequence-batching");
+ boot(new String[] {"hibernate.jdbc.batch_size", "10"}, Invoice.class, Receipt.class);
+ int m = SqlLog.mark();
+ tx(s -> {
+ for (int i = 0; i < 25; i++) {
+ s.persist(new Receipt(i));
+ }
+ });
+ long seqInserts = SqlLog.countSince(m, "insert");
+ long seqSelects = SqlLog.countSince(m, "select");
+ p("SEQUENCE: 25 entities -> %d prepared insert statement(s) with jdbc.batch_size=10, %d sequence selects", seqInserts, seqSelects);
+ int m2 = SqlLog.mark();
+ tx(s -> {
+ for (int i = 0; i < 25; i++) {
+ s.persist(new Invoice(i));
+ }
+ });
+ long idInserts = SqlLog.countSince(m2, "insert");
+ p("IDENTITY: 25 entities -> %d prepared insert statements (one per row)", idInserts);
+ assertTrue(idInserts > seqInserts);
+ }
+
+ @Test
+ void q11_embeddableFlattensIntoOwnerTable() {
+ q(11, "embeddable-columns");
+ boot(Department.class, Employee.class, Project.class);
+ try (Session s = sf.openSession()) {
+ List cols = s.createNativeQuery("select column_name from information_schema.columns where table_name='employee' order by 1", String.class).getResultList();
+ p("columns of table employee: %s", cols);
+ assertTrue(cols.contains("city") && cols.contains("zip"));
+ }
+ }
+
+ @Test
+ void q12_enumeratedOrdinalVersusString() {
+ q(12, "enum-ordinal-vs-string");
+ boot(Department.class, Employee.class, Project.class, RankedEmployee.class);
+ tx(s -> {
+ Employee e = new Employee("Hana", 1, null);
+ e.level = Level.LEAD;
+ s.persist(e);
+ RankedEmployee r = new RankedEmployee("Hana");
+ r.level = Level.LEAD;
+ s.persist(r);
+ });
+ try (Session s = sf.openSession()) {
+ Object str = s.createNativeQuery("select level from employee", Object.class).getSingleResult();
+ Object ord = s.createNativeQuery("select level from rankedemployee", Object.class).getSingleResult();
+ p("@Enumerated(STRING) stores LEAD as: %s", str);
+ p("@Enumerated (default) stores LEAD as: %s (its position in the enum: %s)", ord, java.util.Arrays.toString(Level.values()));
+ }
+ }
+
+ @Test
+ void q13_elementCollectionCreatesATable() {
+ q(13, "element-collection-table");
+ boot(Post.class, Comment.class);
+ p("tables: %s", tables());
+ assertTrue(tables().contains("post_tags"));
+ }
+
+ @Test
+ void q14_inheritanceStrategies() {
+ q(14, "inheritance-strategies");
+ boot(Payment.class, CardPayment.class, BankPayment.class, Shape.class, Circle.class, Square.class);
+ p("tables: %s", tables());
+ tx(s -> {
+ s.persist(new CardPayment(100, "4242"));
+ s.persist(new BankPayment(200, "DE00"));
+ s.persist(new Circle("c", 3));
+ s.persist(new Square("s", 4));
+ });
+ int m = SqlLog.mark();
+ tx(s -> {
+ p("JOINED 'from Payment' returned %d rows", s.createSelectionQuery("from Payment", Payment.class).getResultList().size());
+ p("SINGLE_TABLE 'from Shape' returned %d rows", s.createSelectionQuery("from Shape", Shape.class).getResultList().size());
+ });
+ sql(m);
+ }
+
+ @Test
+ void q15_mappedSuperclassIsNotQueryable() {
+ q(15, "mapped-superclass");
+ boot(Note.class);
+ tx(s -> s.persist(new Note("hello")));
+ p("tables: %s", tables());
+ tx(s -> p("select from the entity Note: %d row(s), created_by column inherited = %s", s.createSelectionQuery("from Note", Note.class).getResultList().size(), s.createSelectionQuery("from Note", Note.class).getSingleResult().createdBy));
+ try {
+ tx(s -> s.createSelectionQuery("from Auditable", Object.class).getResultList());
+ } catch (RuntimeException ex) {
+ p("select from the @MappedSuperclass -> %s", first(ex));
+ }
+ }
+
+ @Test
+ void q16_hashCodeOnGeneratedId() {
+ q(16, "equals-hashcode-generated-id");
+ boot(SetItem.class);
+ Set set = new HashSet<>();
+ SetItem item = new SetItem("x");
+ set.add(item);
+ p("before persist: id=%s, set.contains(item)=%s", item.id, set.contains(item));
+ tx(s -> s.persist(item));
+ p("after persist : id assigned=%s, hashCode changed, set.contains(item)=%s", item.id != null, set.contains(item));
+ assertFalse(set.contains(item));
+ }
+}
diff --git a/hibernate-jpa/src/test/java/lab/hibernate/C_AssociationsTest.java b/hibernate-jpa/src/test/java/lab/hibernate/C_AssociationsTest.java
new file mode 100644
index 0000000..520dbc7
--- /dev/null
+++ b/hibernate-jpa/src/test/java/lab/hibernate/C_AssociationsTest.java
@@ -0,0 +1,262 @@
+package lab.hibernate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import jakarta.persistence.*;
+import java.util.List;
+import lab.hibernate.model.*;
+import org.hibernate.Hibernate;
+import org.hibernate.Session;
+import org.hibernate.graph.GraphSemantic;
+import org.junit.jupiter.api.Test;
+
+class C_AssociationsTest extends Lab {
+
+ private static final Class>[] HR = {Department.class, Employee.class, Project.class, EagerEmployee.class};
+
+ /** 3 departments with 3 employees each. */
+ private void seed() {
+ tx(s -> {
+ for (int d = 1; d <= 3; d++) {
+ Department dep = new Department("D" + d);
+ s.persist(dep);
+ for (int e = 1; e <= 3; e++) {
+ s.persist(new Employee("E" + d + e, 100 * e, dep));
+ s.persist(new EagerEmployee("EE" + d + e, dep));
+ }
+ }
+ });
+ }
+
+ @Test
+ void q17_defaultFetchTypes() throws Exception {
+ q(17, "default-fetch-types");
+ p("default of fetch() on each JPA annotation, read from the API jar:");
+ for (Class> a : List.of(Basic.class, ManyToOne.class, OneToOne.class, OneToMany.class, ManyToMany.class, ElementCollection.class)) {
+ p(" @%-17s -> %s", a.getSimpleName(), a.getMethod("fetch").getDefaultValue());
+ }
+ assertEquals(FetchType.EAGER, ManyToOne.class.getMethod("fetch").getDefaultValue());
+ assertEquals(FetchType.LAZY, OneToMany.class.getMethod("fetch").getDefaultValue());
+ }
+
+ @Test
+ void q18_forgettingTheOwningSide() {
+ q(18, "owning-side");
+ boot(HR);
+ tx(s -> {
+ Department d = new Department("Ops");
+ Employee e = new Employee("Ivy", 1, null);
+ d.employees.add(e); // only the inverse side is set
+ s.persist(d);
+ s.persist(e);
+ });
+ try (Session s = sf.openSession()) {
+ Object fk = s.createNativeQuery("select department_id from employee", Object.class).getSingleResult();
+ p("employee.department_id after d.employees.add(e) only: %s", fk);
+ assertNull(fk);
+ }
+ tx(s -> {
+ Department d = s.createSelectionQuery("from Department", Department.class).getSingleResult();
+ Employee e = s.createSelectionQuery("from Employee", Employee.class).getSingleResult();
+ e.department = d; // set the owning side (mappedBy points here)
+ d.employees.add(e);
+ });
+ try (Session s = sf.openSession()) {
+ Object fk = s.createNativeQuery("select department_id from employee", Object.class).getSingleResult();
+ p("after setting employee.department too : %s", fk);
+ assertNotNull(fk);
+ }
+ }
+
+ @Test
+ void q19_nPlusOne() {
+ q(19, "n-plus-one");
+ boot(HR);
+ seed();
+ int m = SqlLog.mark();
+ tx(s -> {
+ for (Department d : s.createSelectionQuery("from Department", Department.class).getResultList()) {
+ d.getEmployees().size();
+ }
+ });
+ p("naive loop over 3 departments: %d SQL statements (1 + 3)", SqlLog.since(m).size());
+ sql(m);
+ int m2 = SqlLog.mark();
+ tx(s -> {
+ for (Department d : s.createSelectionQuery("select distinct d from Department d join fetch d.employees", Department.class).getResultList()) {
+ d.getEmployees().size();
+ }
+ });
+ p("with join fetch: %d SQL statement", SqlLog.since(m2).size());
+ sql(m2);
+ assertEquals(1, SqlLog.since(m2).size());
+ }
+
+ @Test
+ void q20_joinFetchVersusEntityGraph() {
+ q(20, "join-fetch-vs-entity-graph");
+ boot(HR);
+ seed();
+ int m = SqlLog.mark();
+ tx(s -> {
+ List list = s.createSelectionQuery("select e from Employee e join fetch e.department", Employee.class).getResultList();
+ p("join fetch : department initialized = %s", Hibernate.isInitialized(list.get(0).getDepartment()));
+ });
+ sql(m);
+ int m2 = SqlLog.mark();
+ tx(s -> {
+ EntityGraph g = s.createEntityGraph(Employee.class);
+ g.addAttributeNodes("department");
+ List list = s.createSelectionQuery("from Employee", Employee.class).setEntityGraph(g, GraphSemantic.FETCH).getResultList();
+ p("entity graph : department initialized = %s", Hibernate.isInitialized(list.get(0).getDepartment()));
+ });
+ sql(m2);
+ }
+
+ @Test
+ void q21_batchFetching() {
+ q(21, "batch-fetch-size");
+ boot(new String[] {"hibernate.default_batch_fetch_size", "2"}, HR);
+ seed();
+ int m = SqlLog.mark();
+ tx(s -> {
+ for (Department d : s.createSelectionQuery("from Department", Department.class).getResultList()) {
+ d.getEmployees().size();
+ }
+ });
+ p("default_batch_fetch_size=2, 3 departments: %d SQL statements (1 + ceil(3/2))", SqlLog.since(m).size());
+ sql(m);
+ assertEquals(3, SqlLog.since(m).size());
+ }
+
+ @Test
+ void q22_lazyInitializationException() {
+ q(22, "lazy-initialization-exception");
+ boot(HR);
+ seed();
+ Department d = txr(s -> s.createSelectionQuery("from Department order by id", Department.class).getResultList().get(0));
+ try {
+ d.getEmployees().size();
+ } catch (RuntimeException ex) {
+ p("read a lazy collection after the session closed -> %s", first(ex));
+ }
+ int m = SqlLog.mark();
+ int n = txr(s -> {
+ Department fresh = s.createSelectionQuery("select d from Department d join fetch d.employees where d.id = :id", Department.class).setParameter("id", d.getId()).getSingleResult();
+ return fresh.getEmployees().size();
+ });
+ p("cure 1: fetch it inside the query -> %d employees, %d SQL statement", n, SqlLog.since(m).size());
+ int m2 = SqlLog.mark();
+ List names = txr(s -> s.createSelectionQuery("select e.name from Employee e where e.department.id = :id order by e.name", String.class).setParameter("id", d.getId()).getResultList());
+ p("cure 2: ask for exactly the columns you need -> %s, %d SQL statement", names, SqlLog.since(m2).size());
+ }
+
+ @Test
+ void q23_multipleBagFetchException() {
+ q(23, "multiple-bag-fetch");
+ boot(Post.class, Comment.class);
+ tx(s -> {
+ Post post = new Post("p");
+ post.getTags().add("java");
+ post.getTags().add("jpa");
+ post.getComments().add(new Comment("c1", post));
+ post.getComments().add(new Comment("c2", post));
+ s.persist(post);
+ });
+ try {
+ tx(s -> s.createSelectionQuery("select p from Post p join fetch p.comments join fetch p.tags", Post.class).getResultList());
+ } catch (RuntimeException ex) {
+ p("two List collections in one join fetch -> %s", first(ex));
+ }
+ int m = SqlLog.mark();
+ tx(s -> {
+ Post post = s.createSelectionQuery("select p from Post p join fetch p.comments", Post.class).getSingleResult();
+ s.createSelectionQuery("select p from Post p join fetch p.tags where p in :p", Post.class).setParameter("p", post).getSingleResult();
+ p("fix: one collection per query -> comments=%d tags=%d, %d SQL statements", post.getComments().size(), post.getTags().size(), SqlLog.since(m).size());
+ });
+ }
+
+ @Test
+ void q24_paginationWithCollectionFetch() {
+ q(24, "pagination-join-fetch");
+ boot(HR);
+ seed();
+ int m = SqlLog.mark();
+ tx(s -> {
+ List page = s.createSelectionQuery("select d from Department d join fetch d.employees order by d.id", Department.class).setMaxResults(2).getResultList();
+ p("join fetch of a collection + setMaxResults(2) -> %d departments", page.size());
+ });
+ p("SQL contains a row limit: %s", SqlLog.since(m).stream().anyMatch(x -> x.toLowerCase().contains("fetch first") || x.toLowerCase().contains(" limit ")));
+ sql(m);
+ int m2 = SqlLog.mark();
+ tx(s -> {
+ List ids = s.createSelectionQuery("select d.id from Department d order by d.id", Long.class).setMaxResults(2).getResultList();
+ List page = s.createSelectionQuery("select distinct d from Department d join fetch d.employees where d.id in :ids order by d.id", Department.class).setParameter("ids", ids).getResultList();
+ p("two-step fix -> %d departments", page.size());
+ });
+ sql(m2);
+ }
+
+ @Test
+ void q25_cascadeAndTransientInstances() {
+ q(25, "cascade-transient");
+ boot(HR);
+ try {
+ tx(s -> s.persist(new Employee("Jo", 1, new Department("Unsaved"))));
+ } catch (RuntimeException ex) {
+ p("persist Employee that points at an unsaved Department -> %s", first(ex));
+ }
+ boot2();
+ tx(s -> {
+ Post post = new Post("with cascade");
+ post.getComments().add(new Comment("c", post));
+ s.persist(post); // CascadeType.ALL on Post.comments saves the comment too
+ });
+ long stored = txr(s -> s.createSelectionQuery("select count(c) from Comment c", Long.class).getSingleResult());
+ p("Post -> comments has CascadeType.ALL: comments stored = %d", stored);
+ }
+
+ private void boot2() {
+ sf.close();
+ boot(Post.class, Comment.class);
+ }
+
+ @Test
+ void q26_orphanRemoval() {
+ q(26, "orphan-removal");
+ boot(PurchaseOrder.class, OrderLine.class);
+ Long id = txr(s -> {
+ PurchaseOrder o = new PurchaseOrder("acme");
+ o.addLine(new OrderLine("A"));
+ o.addLine(new OrderLine("B"));
+ s.persist(o);
+ return o.getId();
+ });
+ int m = SqlLog.mark();
+ tx(s -> {
+ PurchaseOrder o = s.find(PurchaseOrder.class, id);
+ o.getLines().remove(0); // no remove() call on the OrderLine
+ });
+ p("removed a line from the collection only:");
+ sql(m);
+ int m2 = SqlLog.mark();
+ tx(s -> s.remove(s.find(PurchaseOrder.class, id)));
+ p("removed the order (cascade ALL):");
+ sql(m2);
+ }
+
+ @Test
+ void q27_eagerManyToOneDefault() {
+ q(27, "eager-many-to-one");
+ boot(HR);
+ seed();
+ int m = SqlLog.mark();
+ tx(s -> s.createSelectionQuery("from EagerEmployee", EagerEmployee.class).getResultList());
+ p("'from EagerEmployee' with the default @ManyToOne: %d SQL statements for 9 employees in 3 departments", SqlLog.since(m).size());
+ sql(m);
+ int m2 = SqlLog.mark();
+ tx(s -> s.createSelectionQuery("from Employee", Employee.class).getResultList());
+ p("'from Employee' with @ManyToOne(fetch = LAZY): %d SQL statement", SqlLog.since(m2).size());
+ assertEquals(1, SqlLog.since(m2).size());
+ }
+}
diff --git a/hibernate-jpa/src/test/java/lab/hibernate/D_CachingTest.java b/hibernate-jpa/src/test/java/lab/hibernate/D_CachingTest.java
new file mode 100644
index 0000000..c7b69fe
--- /dev/null
+++ b/hibernate-jpa/src/test/java/lab/hibernate/D_CachingTest.java
@@ -0,0 +1,100 @@
+package lab.hibernate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.List;
+import lab.hibernate.model.Product;
+import org.hibernate.stat.Statistics;
+import org.junit.jupiter.api.Test;
+
+class D_CachingTest extends Lab {
+
+ private static final String[] L2 = {
+ "hibernate.cache.use_second_level_cache", "true",
+ "hibernate.cache.use_query_cache", "true",
+ "hibernate.cache.region.factory_class", "jcache",
+ "hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider",
+ "hibernate.javax.cache.missing_cache_strategy", "create"
+ };
+
+ private Long seed() {
+ boot(L2, Product.class);
+ return txr(s -> {
+ Product pr = new Product("Pen", 10);
+ s.persist(pr);
+ return pr.getId();
+ });
+ }
+
+ @Test
+ void q28_secondLevelCache() {
+ q(28, "second-level-cache");
+ Long id = seed();
+ Statistics st = sf.getStatistics();
+ st.clear();
+ int m0 = SqlLog.mark();
+ tx(s -> s.find(Product.class, id));
+ p("right after persist() the row is already in L2 (READ_WRITE caches on insert): SQL selects = %d, hits = %d", SqlLog.countSince(m0, "select"), st.getSecondLevelCacheHitCount());
+ sf.getCache().evictAllRegions();
+ st.clear();
+ int m = SqlLog.mark();
+ tx(s -> s.find(Product.class, id));
+ tx(s -> s.find(Product.class, id));
+ tx(s -> s.find(Product.class, id));
+ p("after evictAllRegions(), three find() calls, three different sessions:");
+ p(" SQL selects : %d", SqlLog.countSince(m, "select"));
+ p(" L2 cache hits : %d", st.getSecondLevelCacheHitCount());
+ p(" L2 cache misses : %d", st.getSecondLevelCacheMissCount());
+ p(" L2 cache puts : %d", st.getSecondLevelCachePutCount());
+ assertEquals(1, SqlLog.countSince(m, "select"));
+ }
+
+ @Test
+ void q29_queryCache() {
+ q(29, "query-cache");
+ seed();
+ Statistics st = sf.getStatistics();
+ st.clear();
+ Runnable run = () -> tx(s -> s.createSelectionQuery("from Product p where p.price > 5", Product.class).setCacheable(true).getResultList());
+ int m = SqlLog.mark();
+ run.run();
+ p("run 1: query cache hits=%d misses=%d puts=%d", st.getQueryCacheHitCount(), st.getQueryCacheMissCount(), st.getQueryCachePutCount());
+ run.run();
+ p("run 2: query cache hits=%d misses=%d puts=%d", st.getQueryCacheHitCount(), st.getQueryCacheMissCount(), st.getQueryCachePutCount());
+ p("SQL selects for Product after 2 runs: %d", SqlLog.countSince(m, "select"));
+ tx(s -> s.persist(new Product("Ink", 20))); // any write to the table invalidates cached results
+ run.run();
+ p("run 3 after inserting a Product: query cache hits=%d misses=%d", st.getQueryCacheHitCount(), st.getQueryCacheMissCount());
+ p("SQL selects for Product after 3 runs: %d", SqlLog.countSince(m, "select"));
+ assertEquals(1, st.getQueryCacheHitCount());
+ }
+
+ @Test
+ void q30_bulkUpdatesAndTheCache() {
+ q(30, "bulk-update-vs-l2");
+ Long id = seed();
+ tx(s -> s.find(Product.class, id)); // warm the cache
+ Statistics st = sf.getStatistics();
+
+ tx(s -> s.createMutationQuery("update Product set price = 11").executeUpdate());
+ st.clear();
+ int priceAfterHql = txr(s -> s.find(Product.class, id).getPrice());
+ p("after an HQL bulk update to 11 : find() sees %d (L2 hits=%d misses=%d)", priceAfterHql, st.getSecondLevelCacheHitCount(), st.getSecondLevelCacheMissCount());
+
+ tx(s -> s.createNativeMutationQuery("update product set price = 12").executeUpdate());
+ st.clear();
+ int priceAfterNative = txr(s -> s.find(Product.class, id).getPrice());
+ p("after a native bulk update to 12 : find() sees %d (L2 hits=%d misses=%d)", priceAfterNative, st.getSecondLevelCacheHitCount(), st.getSecondLevelCacheMissCount());
+ assertEquals(11, priceAfterHql);
+
+ tx(s -> s.doWork(c -> {
+ try (var stmt = c.createStatement()) {
+ stmt.executeUpdate("update product set price = 13");
+ }
+ }));
+ st.clear();
+ int priceAfterJdbc = txr(s -> s.find(Product.class, id).getPrice());
+ p("after raw JDBC update to 13 : find() sees %d (L2 hits=%d misses=%d) <- stale: Hibernate was not told", priceAfterJdbc, st.getSecondLevelCacheHitCount(), st.getSecondLevelCacheMissCount());
+ assertEquals(12, priceAfterJdbc);
+ }
+}
diff --git a/hibernate-jpa/src/test/java/lab/hibernate/E_LockingTest.java b/hibernate-jpa/src/test/java/lab/hibernate/E_LockingTest.java
new file mode 100644
index 0000000..30278da
--- /dev/null
+++ b/hibernate-jpa/src/test/java/lab/hibernate/E_LockingTest.java
@@ -0,0 +1,204 @@
+package lab.hibernate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import jakarta.persistence.LockModeType;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicReference;
+import lab.hibernate.model.*;
+import org.hibernate.Session;
+import org.hibernate.Transaction;
+import org.junit.jupiter.api.Test;
+
+class E_LockingTest extends Lab {
+
+ @Test
+ void q31_optimisticLocking() {
+ q(31, "optimistic-locking");
+ boot(Account.class);
+ Long id = txr(s -> {
+ Account a = new Account("ann", 100);
+ s.persist(a);
+ return a.getId();
+ });
+ try (Session a = sf.openSession(); Session b = sf.openSession()) {
+ Transaction ta = a.beginTransaction();
+ Transaction tb = b.beginTransaction();
+ Account fromA = a.find(Account.class, id);
+ Account fromB = b.find(Account.class, id);
+ p("both sessions read balance=%d version=%d", fromA.getBalance(), fromA.getVersion());
+ fromA.balance += 50;
+ ta.commit();
+ p("session A commits +50 -> version is now %d", fromA.getVersion());
+ fromB.balance += 25;
+ int m = SqlLog.mark();
+ try {
+ tb.commit();
+ } catch (RuntimeException ex) {
+ p("session B commits +25 -> %s", first(ex));
+ Throwable c = ex.getCause();
+ if (c != null) {
+ p(" cause: %s", first(c));
+ }
+ tb.rollback();
+ }
+ sql(m);
+ }
+ long finalBalance = txr(s -> s.find(Account.class, id).getBalance());
+ p("final balance: %d (B's stale write was rejected, A's +50 kept)", finalBalance);
+ assertEquals(150, finalBalance);
+ }
+
+ @Test
+ void q32_pessimisticLocking() throws Exception {
+ q(32, "pessimistic-locking");
+ boot(Account.class);
+ Long id = txr(s -> {
+ Account a = new Account("bob", 100);
+ s.persist(a);
+ return a.getId();
+ });
+ CountDownLatch locked = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicReference holderSql = new AtomicReference<>();
+ Thread holder = new Thread(() -> {
+ try (Session s = sf.openSession()) {
+ Transaction t = s.beginTransaction();
+ int m = SqlLog.mark();
+ s.find(Account.class, id, LockModeType.PESSIMISTIC_WRITE);
+ holderSql.set(SqlLog.since(m).get(0).replaceAll("\\s+", " "));
+ locked.countDown();
+ release.await();
+ t.commit();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ holder.start();
+ locked.await();
+ p("session 1: find(..., PESSIMISTIC_WRITE) ran: %s", holderSql.get());
+ try (Session s = sf.openSession()) {
+ Transaction t = s.beginTransaction();
+ try {
+ s.find(Account.class, id, LockModeType.PESSIMISTIC_WRITE, java.util.Map.of("jakarta.persistence.lock.timeout", 0));
+ p("session 2: got the lock (unexpected)");
+ } catch (RuntimeException ex) {
+ p("session 2 with lock.timeout=0 -> %s", first(ex));
+ Throwable c = ex.getCause();
+ if (c != null) {
+ p(" cause: %s", first(c));
+ }
+ }
+ t.rollback();
+ }
+ release.countDown();
+ holder.join();
+ }
+
+ @Test
+ void q33_lostUpdateWithoutAVersion() {
+ q(33, "lost-update");
+ boot(PlainAccount.class);
+ Long id = txr(s -> {
+ PlainAccount a = new PlainAccount("cy", 100);
+ s.persist(a);
+ return a.getId();
+ });
+ try (Session a = sf.openSession(); Session b = sf.openSession()) {
+ Transaction ta = a.beginTransaction();
+ Transaction tb = b.beginTransaction();
+ PlainAccount fromA = a.find(PlainAccount.class, id);
+ PlainAccount fromB = b.find(PlainAccount.class, id);
+ fromA.balance += 50; // deposit 50
+ ta.commit();
+ fromB.balance += 25; // deposit 25, computed from the stale 100
+ int m = SqlLog.mark();
+ tb.commit();
+ sql(m);
+ }
+ long balance = txr(s -> s.find(PlainAccount.class, id).getBalance());
+ p("two deposits (+50, +25) on 100 -> final balance %d, expected 175: A's deposit silently lost", balance);
+ assertEquals(125, balance);
+ }
+
+ @Test
+ void q34_isolationLevels() {
+ q(34, "isolation-levels");
+ boot(PlainAccount.class);
+ Long id = txr(s -> {
+ PlainAccount a = new PlainAccount("di", 100);
+ s.persist(a);
+ return a.getId();
+ });
+ try (Session reader = sf.openSession()) {
+ Object level = reader.createNativeQuery("show transaction_isolation", Object.class).getSingleResult();
+ p("PostgreSQL default isolation level: %s", level);
+ }
+ for (String level : new String[] {"read committed", "repeatable read"}) {
+ tx(s -> s.createNativeMutationQuery("update plainaccount set balance = 100").executeUpdate());
+ try (Session reader = sf.openSession()) {
+ Transaction t = reader.beginTransaction();
+ reader.createNativeMutationQuery("set transaction isolation level " + level).executeUpdate();
+ long first = reader.createNativeQuery("select balance from plainaccount where id = :id", Long.class).setParameter("id", id).getSingleResult();
+ tx(s -> s.createNativeMutationQuery("update plainaccount set balance = 150").executeUpdate());
+ long second = reader.createNativeQuery("select balance from plainaccount where id = :id", Long.class).setParameter("id", id).getSingleResult();
+ p("%-16s: first read %d, another transaction commits 150, second read %d -> %s", level, first, second, first == second ? "repeatable" : "non-repeatable read");
+ t.rollback();
+ }
+ }
+ }
+
+ @Test
+ void q35_versionBumpsOnOwnedCollectionOnly() {
+ q(35, "version-bump-collections");
+ boot(Wallet.class, Coin.class, Folder.class, Doc.class);
+ Long wid = txr(s -> {
+ Wallet w = new Wallet("eve");
+ s.persist(w);
+ return w.getId();
+ });
+ Long fid = txr(s -> {
+ Folder f = new Folder("inbox");
+ s.persist(f);
+ return f.getId();
+ });
+ int wBefore = txr(s -> s.find(Wallet.class, wid).getVersion());
+ tx(s -> s.find(Wallet.class, wid).getCoins().add(new Coin(25)));
+ int wAfter = txr(s -> s.find(Wallet.class, wid).getVersion());
+ p("Wallet (owns a unidirectional @OneToMany): add a Coin -> version %d -> %d", wBefore, wAfter);
+ int fBefore = txr(s -> s.find(Folder.class, fid).getVersion());
+ tx(s -> {
+ Folder f = s.find(Folder.class, fid);
+ Doc d = new Doc("a.txt", f);
+ f.getDocs().add(d);
+ });
+ int fAfter = txr(s -> s.find(Folder.class, fid).getVersion());
+ p("Folder (inverse, mappedBy @OneToMany) : add a Doc -> version %d -> %d", fBefore, fAfter);
+ assertEquals(wBefore + 1, wAfter);
+ assertEquals(fBefore, fAfter);
+ }
+
+ @Test
+ void q36_dynamicUpdate() {
+ q(36, "dynamic-update");
+ boot(Plain.class, Wide.class);
+ Long pid = txr(s -> {
+ Plain x = new Plain("one");
+ s.persist(x);
+ return x.getId();
+ });
+ Long wid = txr(s -> {
+ Wide x = new Wide("one");
+ s.persist(x);
+ return x.getId();
+ });
+ int m = SqlLog.mark();
+ tx(s -> s.find(Plain.class, pid).a = "two");
+ p("default entity, change one column:");
+ sql(m);
+ int m2 = SqlLog.mark();
+ tx(s -> s.find(Wide.class, wid).a = "two");
+ p("@DynamicUpdate entity, change one column:");
+ sql(m2);
+ }
+}
diff --git a/hibernate-jpa/src/test/java/lab/hibernate/F_QueryingTest.java b/hibernate-jpa/src/test/java/lab/hibernate/F_QueryingTest.java
new file mode 100644
index 0000000..4d12c28
--- /dev/null
+++ b/hibernate-jpa/src/test/java/lab/hibernate/F_QueryingTest.java
@@ -0,0 +1,100 @@
+package lab.hibernate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.Root;
+import java.util.List;
+import lab.hibernate.model.*;
+import org.hibernate.Session;
+import org.hibernate.stat.Statistics;
+import org.junit.jupiter.api.Test;
+
+class F_QueryingTest extends Lab {
+
+ record EmployeeRow(String name, int salary) {}
+
+ private void seed() {
+ boot(Department.class, Employee.class, Project.class);
+ tx(s -> {
+ Department d = new Department("Eng");
+ s.persist(d);
+ s.persist(new Employee("Ana", 300, d));
+ s.persist(new Employee("Bo", 100, d));
+ s.persist(new Employee("Cy", 200, d));
+ });
+ }
+
+ @Test
+ void q37_hqlCriteriaNative() {
+ q(37, "hql-criteria-native");
+ seed();
+ int m = SqlLog.mark();
+ tx(s -> {
+ List hql = s.createSelectionQuery("from Employee e where e.salary > :min order by e.salary", Employee.class).setParameter("min", 150).getResultList();
+ p("HQL -> %s", hql.stream().map(Employee::getName).toList());
+
+ CriteriaBuilder cb = sf.getCriteriaBuilder();
+ CriteriaQuery cq = cb.createQuery(Employee.class);
+ Root root = cq.from(Employee.class);
+ cq.select(root).where(cb.gt(root.get("salary"), 150)).orderBy(cb.asc(root.get("salary")));
+ List crit = s.createSelectionQuery(cq).getResultList();
+ p("Criteria -> %s", crit.stream().map(Employee::getName).toList());
+
+ List nat = s.createNativeQuery("select * from employee where salary > 150 order by salary", Employee.class).getResultList();
+ p("Native -> %s", nat.stream().map(Employee::getName).toList());
+ assertEquals(hql.size(), crit.size());
+ assertEquals(hql.size(), nat.size());
+ });
+ sql(m);
+ }
+
+ @Test
+ void q38_dtoProjection() {
+ q(38, "dto-projection");
+ seed();
+ Statistics st = sf.getStatistics();
+ st.clear();
+ int m = SqlLog.mark();
+ tx(s -> {
+ List rows = s.createSelectionQuery("select e.name, e.salary from Employee e order by e.salary", EmployeeRow.class).getResultList();
+ p("record projection -> %s", rows);
+ });
+ p("entities loaded: %d", st.getEntityLoadCount());
+ sql(m);
+ st.clear();
+ tx(s -> s.createSelectionQuery("from Employee", Employee.class).getResultList());
+ p("full entity query loads %d entities", st.getEntityLoadCount());
+ assertEquals(3, st.getEntityLoadCount());
+ }
+
+ @Test
+ void q39_bulkUpdateBypassesPersistenceContext() {
+ q(39, "bulk-update-stale-entity");
+ seed();
+ tx(s -> {
+ Employee e = s.createSelectionQuery("from Employee where name = 'Bo'", Employee.class).getSingleResult();
+ int n = s.createMutationQuery("update Employee set salary = 999").executeUpdate();
+ p("HQL bulk update touched %d rows", n);
+ p("the entity already loaded still says salary = %d", e.salary);
+ s.refresh(e);
+ p("after refresh() it says salary = %d", e.salary);
+ assertEquals(999, e.salary);
+ });
+ }
+
+ @Test
+ void q40_namedQueriesAreValidatedAtStartup() {
+ q(40, "named-query-validation");
+ try {
+ boot(BrokenQuery.class);
+ p("SessionFactory started: true (unexpected)");
+ } catch (RuntimeException ex) {
+ p("buildSessionFactory() failed with %s:", ex.getClass().getName());
+ for (String line : String.valueOf(ex.getMessage()).strip().split("\n")) {
+ p(" %s", line.stripTrailing());
+ }
+ }
+ }
+}
diff --git a/hibernate-jpa/src/test/java/lab/hibernate/Lab.java b/hibernate-jpa/src/test/java/lab/hibernate/Lab.java
new file mode 100644
index 0000000..ec5478b
--- /dev/null
+++ b/hibernate-jpa/src/test/java/lab/hibernate/Lab.java
@@ -0,0 +1,94 @@
+package lab.hibernate;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import org.hibernate.SessionFactory;
+import org.hibernate.cfg.Configuration;
+import org.hibernate.Session;
+import org.junit.jupiter.api.AfterEach;
+
+/** Base class: boots a fresh schema on the local PostgreSQL per test and records a transcript file. */
+abstract class Lab {
+ static final String URL = System.getProperty("lab.db.url", "jdbc:postgresql://localhost:5433/lab");
+
+ SessionFactory sf;
+ private final List lines = new ArrayList<>();
+ private String file;
+
+ void q(int n, String slug) {
+ file = String.format("q%02d-%s.txt", n, slug);
+ }
+
+ void p(String format, Object... args) {
+ lines.add(args.length == 0 ? format : String.format(format, args));
+ }
+
+ /** Print the SQL recorded since the mark, one statement per line. */
+ void sql(int mark) {
+ for (String s : SqlLog.since(mark)) {
+ lines.add("SQL> " + s.replaceAll("\\s+", " ").strip());
+ }
+ }
+
+ Configuration config(String... extra) {
+ Configuration cfg = new Configuration();
+ cfg.setProperty("jakarta.persistence.jdbc.url", URL);
+ cfg.setProperty("jakarta.persistence.jdbc.user", "postgres");
+ cfg.setProperty("hibernate.hbm2ddl.auto", "create-drop");
+ cfg.setProperty("hibernate.generate_statistics", "true");
+ cfg.setProperty("hibernate.session_factory.statement_inspector", SqlLog.class.getName());
+ for (int i = 0; i < extra.length; i += 2) {
+ cfg.setProperty(extra[i], extra[i + 1]);
+ }
+ return cfg;
+ }
+
+ void boot(Class>... entities) {
+ boot(new String[0], entities);
+ }
+
+ void boot(String[] extra, Class>... entities) {
+ Configuration cfg = config(extra);
+ for (Class> c : entities) {
+ cfg.addAnnotatedClass(c);
+ }
+ sf = cfg.buildSessionFactory();
+ }
+
+ void tx(Consumer work) {
+ sf.inTransaction(work);
+ }
+
+ T txr(Function work) {
+ return sf.fromTransaction(work);
+ }
+
+ @AfterEach
+ void finish() {
+ if (sf != null && sf.isOpen()) {
+ sf.close();
+ }
+ if (file == null) {
+ return;
+ }
+ try {
+ Path dir = Path.of("output");
+ Files.createDirectories(dir);
+ Files.writeString(dir.resolve(file), String.join("\n", lines) + "\n");
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ static String first(Throwable t) {
+ String m = String.valueOf(t.getMessage()).strip();
+ int nl = m.indexOf('\n');
+ return t.getClass().getName() + ": " + (nl > 0 ? m.substring(0, nl) : m);
+ }
+}