9.2 KiB
Executable File
12 — Association mappings: what the numbers actually say
← Previous: 11 — Proxies and lazy initialization | Next: 13 — Date and time mapping →
Backs ankurm.com: Hibernate 7 association mappings.
Every claim below was produced by a JUnit test in 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;
this chapter is the collection side of that same story.
Counting the N+1
Seed: 100 authors, 3 books each. NPlusOneTest 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.
MultipleBagFetchException
BagAuthorList has two List (bag-semantics) collections: books and awards. Fetch-joining
both in one JPQL query --
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:
- Fix 1 -- use
Setinstead ofList.BagAuthorSet(identical shape,Setcollections) runs the same double-fetch-join query with zero exceptions and 1 query total. - Fix 2 -- two separate queries, one
JOIN FETCHeach. 2 queries total, no exception, same data assembled in the application.
Raw output: docs/output/association-multiplebag-and-cartesian.txt.
The cartesian-product trap
Fetch-joining two collections that are allowed (both Sets) 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.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 row exists
without querying. Measured in OneToOneLazyTest:
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 is fetched only when the code actually
asks for it, using the same primary key value (@MapsId), via MiUser.
Raw output: docs/output/association-onetoone-lazy-trap.txt.
Cascade and orphanRemoval
Two real behaviors, tested separately in CascadeOrphanTest against CascadeAuthor/CascadeBook --
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.
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.
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 | Next: 13 — Date and time mapping →