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); + } +}