# 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)