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
+197
@@ -0,0 +1,197 @@
|
||||
# 11 — Proxies and LazyInitializationException, verified against Hibernate 7.4.5.Final
|
||||
|
||||
[← Previous: 10 — Mocking JNDI datasources](10-mocking-jndi-datasources.md) | [Next: 12 — Association mappings →](12-association-mappings.md)
|
||||
|
||||
Backs [ankurm.com: proxies and the LazyInitializationException](https://ankurm.com/mastering-hibernate-7-proxies-and-the-lazyinitializationexception/).
|
||||
|
||||
Companion code: [`src/main/java/com/ankurm/hibernatedemo/proxy/`](../src/main/java/com/ankurm/hibernatedemo/proxy/) (entities —
|
||||
[`ProxyBook`](../src/main/java/com/ankurm/hibernatedemo/proxy/ProxyBook.java),
|
||||
[`ProxyPublisher`](../src/main/java/com/ankurm/hibernatedemo/proxy/ProxyPublisher.java),
|
||||
[`ProxyReview`](../src/main/java/com/ankurm/hibernatedemo/proxy/ProxyReview.java)) and
|
||||
[`src/test/java/com/ankurm/hibernatedemo/proxy/`](../src/test/java/com/ankurm/hibernatedemo/proxy/) (six test classes, twelve tests, all green).
|
||||
Raw output: `docs/output/proxy-*.txt`. Environment: Hibernate ORM 7.4.5.Final, Spring Boot
|
||||
4.1.1, Spring Framework 7.0.9, Jakarta Persistence 3.2.0, JDK 25 (Temurin), H2 2.4.240.
|
||||
|
||||
This chapter and [chapter 01](01-get-vs-load.md) are the same mechanism seen from two directions:
|
||||
01 measures what `get()` vs `getReference()` actually do to the persistence context and when a
|
||||
`SELECT` fires; this chapter measures what the *object* `getReference()` hands back actually is,
|
||||
and what breaks when it outlives its session. Read together, they cover the whole proxy
|
||||
lifecycle from creation to `LazyInitializationException`.
|
||||
|
||||
## What a proxy actually is, and the two ways to break naive code with it
|
||||
|
||||
`getReference()` does not hand you back an instance of your entity class. In 7.4.5.Final it
|
||||
hands you a **ByteBuddy-generated nested class named `$HibernateProxy`** —
|
||||
`com.ankurm.hibernatedemo.proxy.ProxyBook$HibernateProxy`, confirmed live in
|
||||
[`docs/output/proxy-lazy-init-and-identity.txt`](output/proxy-lazy-init-and-identity.txt). That is not the Javassist-era naming scheme
|
||||
(`EntityName_$$_javassist_N`) that still circulates in older blog posts and even in some current
|
||||
ones — Hibernate moved its default bytecode provider to ByteBuddy years ago, and 7.4.5 keeps the
|
||||
proxy as a literal static nested class of the entity itself, not a same-package sibling class.
|
||||
|
||||
Two things follow from that, and both are demonstrated in `ProxyIdentityTest`:
|
||||
|
||||
- `proxy instanceof ProxyBook` is `true` — the generated class extends your entity.
|
||||
- `proxy.getClass() == ProxyBook.class` is `false`. Any code that branches on `getClass()`
|
||||
instead of `instanceof`, or relies on the JPA-default `equals()`/`hashCode()` (object
|
||||
identity), silently breaks: a `HashSet<ProxyPublisher>` containing the real, managed instance
|
||||
does not recognize a proxy for the exact same row as a member (`proxyIdentityTest
|
||||
.naiveEqualsAndHashSet_cannotRecognizeProxyAndRealInstanceAsTheSameRow`, asserted, not
|
||||
claimed). Chapter 01's own proxy-identity experiment reaches the identical conclusion from the
|
||||
`get()`/`getReference()` side — see
|
||||
[`01 — get() vs getReference(), Proxy identity experiment`](01-get-vs-load.md#proxy-identity-experiment).
|
||||
|
||||
`Hibernate.getClass(proxy)` is the fix — it unwraps to the real entity class regardless of proxy
|
||||
state, and it's asserted to return `ProxyPublisher.class` even for an uninitialized reference.
|
||||
|
||||
## The exception, verbatim — and it is not one message, it's two
|
||||
|
||||
Every blog post on this topic (including the one this replaces) quotes a single wording for
|
||||
`LazyInitializationException`. Running it for real turns up **two different message templates**,
|
||||
depending on whether the frozen thing is a to-one association or a collection:
|
||||
|
||||
```
|
||||
# Collection (ProxyBook.reviews, a LAZY @OneToMany), accessed after session close:
|
||||
Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews' with key '1' (no session)
|
||||
|
||||
# To-one proxy (a getReference() result), accessed after session close:
|
||||
Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#3] - no session
|
||||
```
|
||||
|
||||
Note the capitalization on the second one too: it's `Could` (capital) and `no session`
|
||||
(lower-case s) — not the commonly-quoted "could not initialize proxy ... no **S**ession". Both
|
||||
are `org.hibernate.LazyInitializationException` (verified: `ex.getClass().getName()` printed and
|
||||
asserted equal to that string), but if you're grepping logs for one exact phrase, you need both.
|
||||
See `LazyInitializationTest` for both reproductions and [`docs/output/proxy-lazy-init-and-identity.txt`](output/proxy-lazy-init-and-identity.txt)
|
||||
for the verbatim capture (also mirrored, unfiltered, in [`docs/output/proxy-lazy-and-identity-run.txt`](output/proxy-lazy-and-identity-run.txt)).
|
||||
|
||||
## `Hibernate.initialize()` / `isInitialized()` / `unproxy()` — confirmed against the 7.4.5 jar
|
||||
|
||||
`javap` against `hibernate-core-7.4.5.Final.jar` (full output in
|
||||
[`docs/output/proxy-settings-javap.txt`](output/proxy-settings-javap.txt)) shows the signatures actually shipped:
|
||||
|
||||
```
|
||||
public static void initialize(java.lang.Object) throws org.hibernate.HibernateException;
|
||||
public static boolean isInitialized(java.lang.Object);
|
||||
public static java.lang.Object unproxy(java.lang.Object);
|
||||
public static <T> T unproxy(T, java.lang.Class<T>);
|
||||
```
|
||||
|
||||
`unproxy()` has **two** overloads, not one — a no-cast version that returns `Object`, and a
|
||||
typed version that takes the target `Class<T>` and returns `T` directly. Both are exercised in
|
||||
`LazyInitializationTest.hibernateUnproxy_...`. A side effect worth knowing: calling
|
||||
`Hibernate.unproxy()` on an uninitialized proxy *initializes it* — `isInitialized()` flips from
|
||||
`false` to `true` as a consequence, not just as a precondition.
|
||||
|
||||
## fetchgraph vs. loadgraph, demonstrated, not just stated
|
||||
|
||||
Every article states the rule ("loadgraph keeps defaults for what's not named; fetchgraph forces
|
||||
everything not named to LAZY") and almost none show it happening. `EntityGraphFetchTest` builds
|
||||
one entity (`ProxyBook`) with two associations of different default fetch types — `publisher`
|
||||
(`@ManyToOne`, default EAGER) and `reviews` (`@OneToMany`, explicit LAZY) — and one named graph
|
||||
that mentions only `reviews`:
|
||||
|
||||
| Hint | SQL joins | `publisher` after fetch | `reviews` after fetch |
|
||||
|---|---|---|---|
|
||||
| none (plain `find()`) | `left join proxy_publisher` only | initialized (mapped EAGER) | **not** initialized (mapped LAZY) |
|
||||
| `jakarta.persistence.loadgraph` | `left join proxy_publisher` **and** `left join proxy_review` | initialized | initialized |
|
||||
| `jakarta.persistence.fetchgraph` | `left join proxy_review` **only** | **not initialized** (forced to LAZY, despite EAGER mapping) | initialized |
|
||||
|
||||
The middle column is the whole point: with `fetchgraph`, `book.getPublisher()` comes back as a
|
||||
`ProxyPublisher$HibernateProxy` even though the mapping says EAGER, because fetchgraph treats
|
||||
the graph as the *entire* fetch plan rather than an addition to the mapping's defaults. Verbatim
|
||||
SQL for all three shapes is in [`docs/output/proxy-entitygraph-fetch-vs-load.txt`](output/proxy-entitygraph-fetch-vs-load.txt) (raw run in
|
||||
[`docs/output/proxy-entitygraph-run.txt`](output/proxy-entitygraph-run.txt)). Chapter 12 covers the collection-side counterpart of this same
|
||||
lazy/eager boundary — N+1 counting and `@BatchSize` — see
|
||||
[`12 — Association mappings`](12-association-mappings.md#counting-the-n1).
|
||||
|
||||
## `hibernate.enable_lazy_load_no_trans`: still there, still works, now flagged `@Unsafe`
|
||||
|
||||
This setting has a reputation for being on its way out. It is not gone in 7.4.5.Final —
|
||||
`org.hibernate.cfg.TransactionSettings.ENABLE_LAZY_LOAD_NO_TRANS` still resolves to the string
|
||||
`hibernate.enable_lazy_load_no_trans` (confirmed by `javap`, see
|
||||
[`docs/output/proxy-settings-javap.txt`](output/proxy-settings-javap.txt)). What *is* new-ish and worth a headline: the constant now
|
||||
carries a `@org.hibernate.cfg.Unsafe` marker annotation — an empty marker interface Hibernate's
|
||||
own source uses to flag settings it does not want you reaching for, without actually removing
|
||||
them. `OpenInViewAndLazyLoadNoTransTest` turns it on
|
||||
(`spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true`) and confirms it functionally
|
||||
does what it always did: a to-one proxy, accessed after its originating session and transaction
|
||||
have both closed, initializes successfully instead of throwing — Hibernate opens a temporary
|
||||
session behind the scenes to service exactly that one access. The `@Unsafe` label is
|
||||
Hibernate's own commentary on why you generally shouldn't reach for this, not a sign it's been
|
||||
deprecated or removed.
|
||||
|
||||
## OSIV: the default, its warning, and what it actually masks
|
||||
|
||||
`spring.jpa.open-in-view` defaults to `true` when left unset, and Boot prints a warning at
|
||||
startup that says exactly that — verified verbatim (not paraphrased) against
|
||||
`org.springframework.boot.jpa.autoconfigure.JpaBaseConfiguration$JpaWebConfiguration`'s
|
||||
bytecode:
|
||||
|
||||
```
|
||||
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed
|
||||
during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
|
||||
```
|
||||
|
||||
(Note the package: `org.springframework.boot.jpa.autoconfigure`, not `org.springframework.boot
|
||||
.autoconfigure.orm.jpa` — Boot 4 split its autoconfigure module and JPA's auto-configuration now
|
||||
lives in the separate `spring-boot-jpa` artifact.)
|
||||
|
||||
Proving the *masking* claim required a real HTTP request — OSIV is implemented by
|
||||
`OpenEntityManagerInViewInterceptor`, which only exists for actual servlet requests, so
|
||||
`OsivDefaultWarningTest` and `OsivDisabledExceptionTest` spin up a real embedded Tomcat
|
||||
(`@SpringBootTest(webEnvironment = RANDOM_PORT)`) with one controller
|
||||
([`OsivBookController`](../src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookController.java)/[`OsivBookService`](../src/test/java/com/ankurm/hibernatedemo/proxy/OsivBookService.java)) that returns a `ProxyBook` entity directly and lets
|
||||
Jackson serialize `getReviews()` on the response-writing thread — deliberately not a DTO,
|
||||
because the DTO is the fix, and the point here is what happens without it. Same code, same
|
||||
data, only the property differs:
|
||||
|
||||
```
|
||||
# open-in-view left unset (Boot default true): GET /osiv/books/1
|
||||
200 {"title":"OSIV Default Book","publisher":{...},"id":1,"reviews":[{"comment":"Rendered fine","id":1}]}
|
||||
|
||||
# open-in-view=false: GET /osiv/books/2 (identical entity graph, identical controller)
|
||||
500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/osiv/books/2"}
|
||||
```
|
||||
|
||||
Server-side log for the second case (captured via `CapturedOutput`, not inferred):
|
||||
|
||||
```
|
||||
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write
|
||||
JSON: Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews'
|
||||
with key '2' (no session)]
|
||||
```
|
||||
|
||||
Worth calling out as its own gotcha: the HTTP error *body* Boot's default `BasicErrorController`
|
||||
returns is a bare `{"timestamp":...,"status":500,...}` even with
|
||||
`server.error.include-message=always` set — the failure happens inside the Jackson
|
||||
`HttpMessageConverter` while writing the response, which Boot reports as a generic 500 rather
|
||||
than routing the original exception's message into the error attributes map. If you only look at
|
||||
the HTTP response, you will not learn why it failed; you have to check the application log,
|
||||
which is exactly what real incident response looks like for this failure mode. Full sequence in
|
||||
[`docs/output/proxy-osiv-and-no-trans.txt`](output/proxy-osiv-and-no-trans.txt) (raw run in [`docs/output/proxy-osiv-run.txt`](output/proxy-osiv-run.txt)).
|
||||
|
||||
## Bytecode enhancement (`hibernate-enhance-maven-plugin`) — not run here
|
||||
|
||||
The article draft this replaces implies bytecode enhancement changes proxy behavior in ways
|
||||
worth demonstrating. Two settings for it are real and confirmed present in 7.4.5.Final —
|
||||
`org.hibernate.cfg.BytecodeSettings.ENHANCER_ENABLE_LAZY_INITIALIZATION`,
|
||||
`ENHANCER_ENABLE_DIRTY_TRACKING`, and `ENHANCER_ENABLE_ASSOCIATION_MANAGEMENT` all exist
|
||||
([`docs/output/proxy-settings-javap.txt`](output/proxy-settings-javap.txt)). Wiring up the actual Maven plugin to enhance classes at
|
||||
build time and diff the resulting behavior was not attempted: a quick check for
|
||||
`org.hibernate.orm.tooling:hibernate-enhance-maven-plugin:7.4.5.Final` and
|
||||
`org.hibernate.orm:hibernate-enhance-maven-plugin:7.4.5.Final` on Maven Central both came back
|
||||
"could not be resolved" in this sandbox. Per the brief's own guidance, this looked like a rabbit
|
||||
hole rather than a quick add — flagging it honestly rather than guessing at what enhancement
|
||||
would do differently here.
|
||||
|
||||
## Testcontainers, Docker, and the honest gap
|
||||
|
||||
Not applicable to this chapter directly (see [chapter 09](09-testing-in-memory-databases.md) for
|
||||
that), but it's worth noting here too: everything above ran against a single shared H2
|
||||
in-memory database in a sandboxed container. No Docker was available, so nothing here was
|
||||
cross-checked against a real Postgres/MySQL proxy-and-lazy-loading story — the
|
||||
LazyInitializationException and proxy identity mechanics are Hibernate-internal and
|
||||
database-agnostic, but that claim itself rests on understanding of Hibernate's architecture
|
||||
rather than an executed cross-database test.
|
||||
|
||||
[← Previous: 10 — Mocking JNDI datasources](10-mocking-jndi-datasources.md) | [Next: 12 — Association mappings →](12-association-mappings.md)
|
||||
Reference in New Issue
Block a user