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
+182
@@ -0,0 +1,182 @@
|
||||
# 06 — Hibernate 7 Natural IDs — what L1 and L2 actually give you, measured
|
||||
|
||||
[← Previous: 05 — JPA persistence annotations](05-jpa-persistence-annotations.md) | [Next: 07 — Immutable entities →](07-immutable-entities.md)
|
||||
|
||||
Backs [ankurm.com: Hibernate 7 natural IDs](https://ankurm.com/master-hibernate-7-natural-ids-the-definitive-guide-for-high-performance-java-apps/).
|
||||
|
||||
Post 4865 makes several precise claims about what natural-id resolution buys you with and
|
||||
without a second-level cache. All of them were tested with Hibernate Statistics rather than
|
||||
taken on faith — see [`docs/output/naturalid-tests.txt`](output/naturalid-tests.txt) for the verbatim counts and the test
|
||||
classes under [`src/test/java/com/ankurm/hibernatedemo/naturalid/`](../src/test/java/com/ankurm/hibernatedemo/naturalid/) for exact setup.
|
||||
|
||||
## The API surface: three methods, not two
|
||||
|
||||
`javap` on `org.hibernate.Session` in `hibernate-core-7.4.5.Final.jar` confirms all three natural-id
|
||||
entry points exist, each with `Class` and entity-name (`String`) overloads:
|
||||
|
||||
```
|
||||
NaturalIdLoadAccess<T> byNaturalId(Class<T>)
|
||||
SimpleNaturalIdLoadAccess<T> bySimpleNaturalId(Class<T>)
|
||||
NaturalIdMultiLoadAccess<T> byMultipleNaturalId(Class<T>)
|
||||
```
|
||||
|
||||
Post 4865 only ever demonstrates `byNaturalId(...).using(...).load()` and
|
||||
`byMultipleNaturalId(...).multiLoad(...)`. It never mentions `bySimpleNaturalId`, which is the
|
||||
more ergonomic form for the single-field case the article's own `Product`/`sku` example uses —
|
||||
`session.bySimpleNaturalId(Product.class).load("SKU-123")` instead of
|
||||
`.byNaturalId(Product.class).using("sku", "SKU-123").load()`. Not wrong, just an incomplete API
|
||||
tour; [`NaturalIdL1CacheTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL1CacheTest.java) and [`NaturalIdL2CacheTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdL2CacheTest.java) use `bySimpleNaturalId` throughout to
|
||||
close that gap, exercising [`NaturalIdProduct`](../src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdProduct.java) and [`CachedNaturalIdProduct`](../src/main/java/com/ankurm/hibernatedemo/naturalid/CachedNaturalIdProduct.java) respectively.
|
||||
|
||||
## The central question: does L1 save a query, with NO L2 cache configured?
|
||||
|
||||
**Yes — confirmed exactly as the article claims.** `NaturalIdL1CacheTest` persists a row, then
|
||||
calls `session.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-1")` twice in the *same*
|
||||
session, with no `@NaturalIdCache`, no `@Cache`, no L2 provider configured anywhere:
|
||||
|
||||
- 1st call: 1 query (`getPrepareStatementCount()` goes from 0 to 1)
|
||||
- 2nd call, same session: **still 1** — zero additional queries, and the returned reference is
|
||||
`isSameAs()` the first
|
||||
|
||||
A second test in the same class proves the flip side the article also asserts: a **new**
|
||||
session repeating the identical lookup *does* re-fire the query (cumulative count goes from 1 to
|
||||
2). So the L1 natural-id-to-PK map is real, it does save a query on repeat lookups, and it dies
|
||||
with the session exactly as documented.
|
||||
|
||||
## Turning on L2: `hibernate-jcache` + `ehcache`
|
||||
|
||||
The article recommends "@NaturalIdCache is mandatory for cross-session performance" without
|
||||
showing numbers. To get real ones, this required going online (these two dependencies were not
|
||||
already warm in the sandbox's `~/.m2`):
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-jcache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.ehcache</groupId>
|
||||
<artifactId>ehcache</artifactId>
|
||||
<version>3.10.8</version>
|
||||
<classifier>jakarta</classifier>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.glassfish.jaxb</groupId>
|
||||
<artifactId>jaxb-runtime</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Two things worth flagging for anyone reproducing this: (1) `hibernate-jcache` correctly pulls
|
||||
`javax.cache:cache-api` — **not** a `jakarta.cache` artifact, because JSR-107 (JCache) was never
|
||||
migrated to the Jakarta namespace, unlike JPA/Bean Validation/etc.; (2) plain `org.ehcache:ehcache`
|
||||
resolves but drags in an ancient `javax.xml.bind:jaxb-api` transitively through
|
||||
`jaxb-runtime` that fails to resolve from Maven Central (a dead `maven.java.net` mirror) — the
|
||||
`jakarta` classifier plus excluding `jaxb-runtime` sidesteps it cleanly.
|
||||
|
||||
Settings used (`hibernate.cache.region.factory_class=jcache`,
|
||||
`hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider`) are exercised in
|
||||
`NaturalIdL2CacheTest`, booted as a standalone `SessionFactory` (not through the shared Spring
|
||||
context) so the L2 configuration doesn't leak into the other agents' tests. **This is the same
|
||||
`hibernate-jcache` dependency that turns out to have a much bigger, repo-wide side effect than
|
||||
"enables the natural-id cache when you ask for it" — see
|
||||
[chapter 09's writeup of `JCacheOnClasspathAutoEnablesL2Test`](09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not)
|
||||
for why `application.yml` in this repo pins `hibernate.cache.use_second_level_cache: false`
|
||||
explicitly, everywhere, regardless of what any individual chapter wants.**
|
||||
|
||||
## The surprise: `@NaturalIdCache` populates on INSERT, not on first lookup
|
||||
|
||||
The natural mental model is "L2 cache fills on the first miss." That is only half true.
|
||||
`bySimpleNaturalId_withNaturalIdCache_crossSession_savesTheQuery` shows that immediately after
|
||||
`persist()` + `commit()` on a `@NaturalIdCache`-annotated entity, `Statistics.getNaturalIdCachePutCount()`
|
||||
is already `1` — **before anyone has looked the entity up by its natural id at all.** The
|
||||
consequence: both a same-process "first" lookup and a genuinely new-session lookup resolve with
|
||||
**zero** additional queries and register as cache hits (`getNaturalIdCacheHitCount()` = 2 across
|
||||
both). If your mental model of L2 warm-up is "miss once per cold value," that is wrong for
|
||||
entities inserted through Hibernate itself.
|
||||
|
||||
To see the miss-then-hit path the article's mental model actually describes,
|
||||
`bySimpleNaturalId_rowInsertedOutsideHibernate_firstLookupIsARealMiss_secondIsARealHit` inserts a
|
||||
row via raw JDBC (bypassing Hibernate's persist-time cache population entirely). *That* row
|
||||
produces a real recorded miss + put on the first `bySimpleNaturalId` call (1 query, 1 miss,
|
||||
1 put), and a real hit with zero additional queries on the second, brand-new-session call. Both
|
||||
behaviors are real; the article only describes the second one, and most natural-id rows in a
|
||||
typical app are inserted through Hibernate, which means the first (surprising) path is actually
|
||||
the common case in practice, not the edge case.
|
||||
|
||||
## Mutability: immutable natural ids are enforced, not just conventionally recommended
|
||||
|
||||
Post 4865 recommends immutability as a best practice but doesn't say what enforcement exists.
|
||||
[`NaturalIdMutabilityTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdMutabilityTest.java) shows Hibernate actively checks it: mutating a
|
||||
`@NaturalId` field left at its default (`mutable = false`, [`ImmutableNaturalIdEntity`](../src/main/java/com/ankurm/hibernatedemo/naturalid/ImmutableNaturalIdEntity.java)) and flushing throws
|
||||
|
||||
```
|
||||
org.hibernate.HibernateException: An immutable natural identifier of entity
|
||||
...ImmutableNaturalIdEntity was altered from `CODE-A` to `CODE-B`
|
||||
```
|
||||
|
||||
This fires even with no `@Column(updatable = false)` guard on the column — it is Hibernate's own
|
||||
natural-id consistency check, not a side effect of a JPA column setting. `@NaturalId(mutable =
|
||||
true)` ([`MutableNaturalIdEntity`](../src/main/java/com/ankurm/hibernatedemo/naturalid/MutableNaturalIdEntity.java)), by contrast, flushes the change through cleanly (verified via a native query reading the
|
||||
updated column back), and a subsequent `bySimpleNaturalId` lookup by the *old* value correctly
|
||||
returns `null` while the *new* value resolves to the same row — no stale L1 entry survives the
|
||||
mutation within the same test's fresh sessions.
|
||||
|
||||
## Composite natural ids: the article's exact code sample, verified
|
||||
|
||||
[`Department`](../src/main/java/com/ankurm/hibernatedemo/naturalid/Department.java)'s two-field `@NaturalId` (`company`, `deptCode`) is lifted straight from post
|
||||
4865's example. [`CompositeNaturalIdTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/CompositeNaturalIdTest.java) proves it actually works: two [`Department`](../src/main/java/com/ankurm/hibernatedemo/naturalid/Department.java) rows with the
|
||||
*same* `deptCode` ("ENG-01") at two *different* [`Company`](../src/main/java/com/ankurm/hibernatedemo/naturalid/Company.java) instances resolve independently via
|
||||
`session.byNaturalId(Department.class).using("company", acme).using("deptCode", "ENG-01").load()`,
|
||||
in exactly one query. The generated SQL, captured verbatim:
|
||||
|
||||
```sql
|
||||
select d1_0.id,c1_0.id,c1_0.name,d1_0.dept_code,d1_0.name
|
||||
from department d1_0
|
||||
left join company c1_0 on c1_0.id=d1_0.company_id
|
||||
where d1_0.company_id=? and d1_0.dept_code=?
|
||||
```
|
||||
|
||||
Note it filters on `company_id` (the FK column), not by joining into `Company`'s own natural id
|
||||
or PK independently — the composite key is resolved as a single WHERE clause, as expected.
|
||||
|
||||
## The equals/hashCode advice holds up — with one nuance the article doesn't mention
|
||||
|
||||
Posts 4864/4865 recommend basing `equals()`/`hashCode()` on the natural id rather than the
|
||||
surrogate id, specifically to avoid the "hash code changes after `persist()`" trap. This was
|
||||
tested head-to-head against the surrogate-id version (see the sibling `persistenceannotations`
|
||||
package's [`IdBasedEqualsHashSetTrapTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsHashSetTrapTest.java), which reproduces the surrogate-id failure — see
|
||||
[chapter 05](05-jpa-persistence-annotations.md#the-equalshashcode-hashset-trap--reproduced-end-to-end)):
|
||||
with natural-id-based `equals()`/`hashCode()` ([`NaturalIdEqualsEntity`](../src/main/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsEntity.java)), an entity added to a `HashSet` *before* `persist()`
|
||||
is still found by `contains()` *after* `persist()` — the advice is correct, because the natural
|
||||
id (and hence the hash code) is set at construction time and never changes.
|
||||
|
||||
The nuance: **two distinct transient objects that happen to share the same natural id value are
|
||||
already `equals()` to each other, before either is persisted.** [`NaturalIdEqualsHashSetTest`](../src/test/java/com/ankurm/hibernatedemo/naturalid/NaturalIdEqualsHashSetTest.java)
|
||||
shows a `HashSet.add()` on a second transient instance with the same `sku` but a different
|
||||
`name` is silently rejected as a duplicate. That is usually the intended behavior (natural ids
|
||||
are supposed to be unique business keys), but it means natural-id-based equality can mask a bug
|
||||
where two *meant-to-be-different* objects were accidentally constructed with the same business
|
||||
key before either ever reached the database — a failure mode the surrogate-id approach cannot
|
||||
produce (two transient surrogate-id objects are never accidentally "the same" via `equals()`
|
||||
unless you specifically wrote id-based equality, which the article correctly warns against for
|
||||
other reasons).
|
||||
|
||||
## Numbers summary
|
||||
|
||||
| Scenario | Queries | Cache hits/misses | Source |
|
||||
|---|---|---|---|
|
||||
| No L2, `bySimpleNaturalId` x2, same session | 1, then 1 (0 new) | n/a | `naturalid-tests.txt` |
|
||||
| No L2, `bySimpleNaturalId`, new session | 1, then 2 cumulative (+1) | n/a | `naturalid-tests.txt` |
|
||||
| `@NaturalIdCache`, insert then 2 lookups (own inserts) | 0 additional after insert | 2 hits, 0 miss | `naturalid-tests.txt` |
|
||||
| `@NaturalIdCache`, row inserted outside Hibernate | 1 (miss), then 1 cumulative (0 new) | 1 miss+put, then 1 hit | `naturalid-tests.txt` |
|
||||
| Composite natural id lookup | 1 | n/a | `naturalid-tests.txt` |
|
||||
|
||||
All counts measured via `SessionFactory.getStatistics()` in this sandbox; treat absolute timings
|
||||
as indicative only, but the query *counts* are exact and reproducible (deterministic, not
|
||||
timing-based).
|
||||
|
||||
The three natural-id entry points on `Session`, read straight off the jar, are in [`docs/output/naturalid-javap-session.txt`](output/naturalid-javap-session.txt).
|
||||
|
||||
[← Previous: 05 — JPA persistence annotations](05-jpa-persistence-annotations.md) | [Next: 07 — Immutable entities →](07-immutable-entities.md)
|
||||
Reference in New Issue
Block a user