Files
hibernate-demo/docs/12-association-mappings.md
T

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 Set instead of List. BagAuthorSet (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.

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 →