Add Hibernate 7 batches 2-6, batch 7, and batch 8: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, Hibernate Validator CDI integration, aggregate functions, sorting, pagination, interceptors, and Hibernate Search 8 (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)
This commit is contained in:
Executable
+171
@@ -0,0 +1,171 @@
|
||||
# 12 — Association mappings: what the numbers actually say
|
||||
|
||||
[← Previous: 11 — Proxies and lazy initialization](11-proxies-and-lazy-initialization.md) | [Next: 13 — Date and time mapping →](13-date-and-time-mapping.md)
|
||||
|
||||
Backs [ankurm.com: Hibernate 7 association mappings](https://ankurm.com/master-hibernate-7-association-mappings-the-ultimate-guide-for-high-performance-java-apps/).
|
||||
|
||||
Every claim below was produced by a JUnit test in [`src/test/java/com/ankurm/hibernatedemo/association/`](../src/test/java/com/ankurm/hibernatedemo/association/),
|
||||
run against Hibernate 7.4.5.Final / H2 2.4.240, with `hibernate.generate_statistics=true` and
|
||||
`Statistics.getPrepareStatementCount()` as the counter. Nothing here is asserted from memory --
|
||||
every number has a test that fails if the number changes. Chapter 11 covers the same lazy vs.
|
||||
eager boundary from the to-one/proxy side (`LazyInitializationException`, `fetchgraph` vs.
|
||||
`loadgraph`) -- see [`11 — Proxies and lazy initialization`](11-proxies-and-lazy-initialization.md);
|
||||
this chapter is the collection side of that same story.
|
||||
|
||||
## Counting the N+1
|
||||
|
||||
Seed: 100 authors, 3 books each. [`NPlusOneTest`](../src/test/java/com/ankurm/hibernatedemo/association/NPlusOneTest.java) measures four strategies against the identical
|
||||
data:
|
||||
|
||||
| Strategy | Query count | Test |
|
||||
|---|---|---|
|
||||
| Naive lazy iteration (`author.getBooks().size()` in a loop) | **101** | `naiveLazyIteration_firesOneQueryPerAuthor_theClassicNPlusOne` |
|
||||
| JPQL `JOIN FETCH` | **1** | `jpqlFetchJoin_firesExactlyOneQuery` |
|
||||
| `@EntityGraph` (`jakarta.persistence.fetchgraph` hint) | **1** | `entityGraph_firesExactlyOneQuery` |
|
||||
| `@BatchSize(size = 10)` | **11** | `batchSize10_collapsesNPlusOneIntoCeilNOverBatchSizePlusOne` |
|
||||
|
||||
The batch-size math is worth spelling out: with 100 authors and a batch size of 10, Hibernate
|
||||
issues `ceil(100 / 10) = 10` batched `IN (...)` selects for the collections, plus the 1 select
|
||||
for the authors themselves -- `11` total, exactly matching `ceil(N / batchSize) + 1`. This is not
|
||||
an approximation; `BatchSizeSweepTest`-style math generalizes: doubling `batchSize` to 20 would
|
||||
give `ceil(100/20)+1 = 6`.
|
||||
|
||||
Raw output: [`docs/output/association-n-plus-one.txt`](output/association-n-plus-one.txt).
|
||||
|
||||
## MultipleBagFetchException
|
||||
|
||||
[`BagAuthorList`](../src/main/java/com/ankurm/hibernatedemo/association/BagAuthorList.java) has two `List` (bag-semantics) collections: `books` and `awards`. Fetch-joining
|
||||
both in one JPQL query --
|
||||
|
||||
```java
|
||||
SELECT a FROM BagAuthorList a JOIN FETCH a.books JOIN FETCH a.awards
|
||||
```
|
||||
|
||||
-- throws. The **verbatim** exception, captured from a real run:
|
||||
|
||||
```
|
||||
wrapper class: java.lang.IllegalArgumentException
|
||||
root cause class: org.hibernate.loader.MultipleBagFetchException
|
||||
message: cannot simultaneously fetch multiple bags: [com.ankurm.hibernatedemo.association.BagAuthorList.awards, com.ankurm.hibernatedemo.association.BagAuthorList.books]
|
||||
```
|
||||
|
||||
**Correction worth flagging**: `EntityManager.createQuery(...).getResultList()` wraps this as
|
||||
`java.lang.IllegalArgumentException`, not `jakarta.persistence.PersistenceException`. If your
|
||||
code catches `PersistenceException` expecting to handle Hibernate query failures uniformly, this
|
||||
one slips past it.
|
||||
|
||||
Two fixes, both measured, in [`BagFetchTest`](../src/test/java/com/ankurm/hibernatedemo/association/BagFetchTest.java):
|
||||
|
||||
- **Fix 1 -- use `Set` instead of `List`.** [`BagAuthorSet`](../src/main/java/com/ankurm/hibernatedemo/association/BagAuthorSet.java) (identical shape, `Set` collections)
|
||||
runs the same double-fetch-join query with **zero** exceptions and **1** query total.
|
||||
- **Fix 2 -- two separate queries**, one `JOIN FETCH` each. **2** queries total, no exception,
|
||||
same data assembled in the application.
|
||||
|
||||
Raw output: [`docs/output/association-multiplebag-and-cartesian.txt`](output/association-multiplebag-and-cartesian.txt).
|
||||
|
||||
## The cartesian-product trap
|
||||
|
||||
Fetch-joining two collections that *are* allowed (both `Set`s) does not throw, but it does not
|
||||
avoid the underlying join math either. With 1 author, 4 books, 3 awards, fetch-joining both
|
||||
collections in one query:
|
||||
|
||||
- Raw SQL join row count: **12** (4 x 3 -- one row per (book, award) pair).
|
||||
- Entities returned to the application (via `SELECT DISTINCT` + Hibernate's root-entity
|
||||
de-duplication): **1**, fully populated with all 4 books and all 3 awards.
|
||||
|
||||
The row count explosion is real and happens at the database and JDBC layer regardless of how
|
||||
many entities eventually come back -- for large collections this is where "the query is fast in
|
||||
isolation but the app is slow" reports come from.
|
||||
|
||||
## The `@OneToOne` lazy trap
|
||||
|
||||
[`LazyUser`](../src/main/java/com/ankurm/hibernatedemo/association/LazyUser.java)`.profile` is the non-owning (`mappedBy`) side of an optional `@OneToOne`, declared
|
||||
`FetchType.LAZY`. Without bytecode enhancement, Hibernate cannot build a lazy proxy for it -- it
|
||||
has no foreign key of its own to defer against, so it cannot know whether a [`LazyProfile`](../src/main/java/com/ankurm/hibernatedemo/association/LazyProfile.java) row exists
|
||||
without querying. Measured in [`OneToOneLazyTest`](../src/test/java/com/ankurm/hibernatedemo/association/OneToOneLazyTest.java):
|
||||
|
||||
```
|
||||
LazyUser.find(): 2 queries fired BEFORE touching getProfile() at all
|
||||
after touching getProfile(): 2 queries total (no further query needed -- it already ran eagerly)
|
||||
```
|
||||
|
||||
The annotation says `LAZY`; the runtime behavior is eager. This is the trap.
|
||||
|
||||
The fix is not `@MapsId` alone -- it's removing the inverse mapping and querying by the shared
|
||||
primary key on demand:
|
||||
|
||||
```
|
||||
MiUser.find() (no mappedBy field at all): 1 query
|
||||
explicit MiProfile.find() by shared PK when actually needed: 2 total queries
|
||||
```
|
||||
|
||||
Loading the user alone costs exactly 1 query; [`MiProfile`](../src/main/java/com/ankurm/hibernatedemo/association/MiProfile.java) is fetched only when the code actually
|
||||
asks for it, using the same primary key value (`@MapsId`), via [`MiUser`](../src/main/java/com/ankurm/hibernatedemo/association/MiUser.java).
|
||||
|
||||
Raw output: [`docs/output/association-onetoone-lazy-trap.txt`](output/association-onetoone-lazy-trap.txt).
|
||||
|
||||
## Cascade and orphanRemoval
|
||||
|
||||
Two real behaviors, tested separately in [`CascadeOrphanTest`](../src/test/java/com/ankurm/hibernatedemo/association/CascadeOrphanTest.java) against [`CascadeAuthor`](../src/main/java/com/ankurm/hibernatedemo/association/CascadeAuthor.java)/[`CascadeBook`](../src/main/java/com/ankurm/hibernatedemo/association/CascadeBook.java) --
|
||||
and one of them is a correction of the common claim.
|
||||
|
||||
**Correction**: the frequently repeated claim is "assigning a new collection to an
|
||||
`orphanRemoval=true` field silently deletes the old rows." That's not what happens.
|
||||
`CascadeOrphanTest.cascadeAllPlusOrphanRemoval_reassigningTheCollectionThrowsInsteadOfSilentlyDeleting`
|
||||
shows Hibernate detects the dereferenced managed collection and throws at commit time:
|
||||
|
||||
```
|
||||
jakarta.persistence.RollbackException: Error while committing the transaction
|
||||
[A collection with orphan deletion was no longer referenced by the owning entity instance:
|
||||
com.ankurm.hibernatedemo.association.CascadeAuthor.books]
|
||||
root cause: org.hibernate.HibernateException
|
||||
```
|
||||
|
||||
The scenario that *does* silently delete is mutating the **same** managed collection instance in
|
||||
place -- e.g. `managed.getBooks().removeIf(...)`, the realistic pattern that reaches production.
|
||||
That test shows books going from 3 to 1 with no exception:
|
||||
|
||||
```
|
||||
cascade=ALL + orphanRemoval=true, in-place removeIf(): books before=3, books after=1
|
||||
```
|
||||
|
||||
**No `orphanRemoval`**: removing a child from the collection and flushing does nothing to the
|
||||
row -- no DELETE, no FK update. The row and its FK are untouched:
|
||||
|
||||
```
|
||||
orphanRemoval=false: after removing book2 from author.books and flushing,
|
||||
book2 row still exists = true, author_id still = 1
|
||||
```
|
||||
|
||||
Raw output: [`docs/output/association-cascade-orphan.txt`](output/association-cascade-orphan.txt).
|
||||
|
||||
## The owning side
|
||||
|
||||
Mutating only the inverse (`mappedBy`) side of a bidirectional association -- adding a book to
|
||||
`author.getBooks()` without ever calling `book.setAuthor(author)` -- never persists anything.
|
||||
The owning side (the entity holding the `@JoinColumn`) is the only thing Hibernate looks at when
|
||||
deciding what to write:
|
||||
|
||||
```
|
||||
owning side test: mutated only author2.getBooks().add(book) (inverse side),
|
||||
book.author after flush = null (FK not written)
|
||||
```
|
||||
|
||||
Raw output: [`docs/output/association-cascade-orphan.txt`](output/association-cascade-orphan.txt).
|
||||
|
||||
## Summary
|
||||
|
||||
| Claim | Verified value |
|
||||
|---|---|
|
||||
| Naive N+1 for 100 authors | 101 queries |
|
||||
| Fetch join / entity graph | 1 query |
|
||||
| `@BatchSize(10)` for 100 authors | 11 queries (`ceil(100/10)+1`) |
|
||||
| `MultipleBagFetchException` wrapper | `IllegalArgumentException`, not `PersistenceException` |
|
||||
| Cartesian join (4x3) | 12 SQL rows -> 1 deduplicated entity |
|
||||
| `mappedBy @OneToOne(LAZY)` | Still 2 queries -- eager despite the annotation |
|
||||
| `@MapsId` + no inverse field | 1 query for the parent; profile fetched only on demand |
|
||||
| Reassigning an orphanRemoval collection | Throws `HibernateException`, does not silently delete |
|
||||
| In-place mutation of an orphanRemoval collection | Does silently delete |
|
||||
| Inverse-side-only mutation | FK never written |
|
||||
|
||||
[← Previous: 11 — Proxies and lazy initialization](11-proxies-and-lazy-initialization.md) | [Next: 13 — Date and time mapping →](13-date-and-time-mapping.md)
|
||||
Reference in New Issue
Block a user