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:
2026-09-20 06:06:42 +00:00
committed by Claude
commit 8568c0ce6c
330 changed files with 23668 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# 05 — JPA persistence annotations in Hibernate 7.4.5 / Jakarta Persistence 3.2 — what's actually new or broken
[← Previous: 04 — Annotations vs. XML mappings](04-annotations-vs-xml.md) | [Next: 06 — Natural IDs →](06-natural-ids.md)
Backs [ankurm.com: mastering JPA persistence annotations in Hibernate 7](https://ankurm.com/mastering-hibernate-7-the-ultimate-guide-to-jpa-persistence-annotations/).
Post 4864 is a solid annotation catalogue; this chapter deliberately does not repeat it. Instead
it covers what is wrong, deprecated, or new in 3.2 that the article predates or gets slightly
wrong, each verified by `javap` on the real jars and a runnable test — see
[`docs/output/persistenceannotations-tests.txt`](output/persistenceannotations-tests.txt) and
[`src/test/java/com/ankurm/hibernatedemo/persistenceannotations/`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/).
## `@Temporal` is formally `@Deprecated(since = "3.2")` — and it is a silent no-op, not silent-silent
`javap -v` on `jakarta.persistence.Temporal` in `jakarta.persistence-api-3.2.0.jar` shows:
```
RuntimeVisibleAnnotations:
java.lang.Deprecated(since="3.2")
```
Post 4864 says (correctly) that `@Temporal` isn't needed for `java.time` types. What it doesn't
say: putting `@Temporal` on a `java.time.LocalDate` field anyway does **not** boot silently.
[`TemporalOnJavaTimeTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnJavaTimeTest.java) shows Hibernate logs a WARN at boot for every such field, using [`TemporalOnLocalDateEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/TemporalOnLocalDateEntity.java):
```
HHH90000033: Encountered use of deprecated annotation [interface jakarta.persistence.Temporal]
at ...TemporalOnLocalDateEntity.eventDate.
```
The mapping itself is unaffected — the field round-trips identically with or without the
annotation — but "silent" is the wrong word for what happens; it is a one-line-per-field boot
warning, which is worth knowing if you're trying to track down noisy startup logs after a
Hibernate upgrade. (Chapter 13 measures the same deprecation warning against an `Instant` field
and covers the rest of `@Temporal`'s replacement, `@TimeZoneStorage` — see
[`13 — Date and time mapping`](13-date-and-time-mapping.md#temporal-verified-deprecated-and-verified-harmless-when-misapplied).)
## `@Enumerated` default (ORDINAL): the real failure mode, reproduced
The article correctly recommends `EnumType.STRING` over the ORDINAL default, but doesn't show
the failure concretely. [`EnumOrdinalDefaultTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/EnumOrdinalDefaultTest.java) does: persist a row with `OrderStatus.SHIPPED`
(ordinal 1 in the original 3-constant enum, [`EnumDefaultOrdinalEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumDefaultOrdinalEntity.java)), then read the **same physical row** back through a
second entity/enum pair ([`EnumReorderedV2Entity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumReorderedV2Entity.java)) where a new constant (`PENDING_REVIEW`) was inserted *before* `SHIPPED`.
No exception anywhere — the row silently comes back tagged `PENDING_REVIEW`. This is exactly the
"someone edited the enum without a migration" scenario, reproduced with two real
`SessionFactory` instances against the same physical H2 database (needed because Hibernate
refuses to map the same table twice inside one persistence unit, so this can't be done inside a
single Spring context).
## `@JdbcTypeCode(SqlTypes.JSON)` works on H2 2.4.240 — but only with a JSON mapper on the classpath
The article recommends `@JdbcTypeCode(SqlTypes.JSON)` without dependency caveats. First attempt
against the existing `hibernate-demo` pom (Boot starter + Data JPA + H2, no Jackson) failed
outright, tested against [`JsonColumnEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnEntity.java) in [`JsonColumnOnH2Test`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/JsonColumnOnH2Test.java):
```
org.hibernate.HibernateException: Could not find a FormatMapper for the JSON format, which is
required for mapping JSON types. JSON FormatMapper configuration is automatic, but requires that
you have either Jackson or a JSONB implementation like Yasson on the class path.
```
This matters because `spring-boot-starter-data-jpa` does **not** pull in Jackson — most real
apps have Jackson anyway (via `spring-boot-starter-web`), which is presumably why this is easy
to miss. Adding Jackson 3 makes it work cleanly, H2 storing it as a native `JSON` column type:
```xml
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.1.5</version>
</dependency>
```
(Note the groupId: Boot 4.1.1's BOM calls this artifact `tools.jackson:jackson-bom` at the BOM
level, but the actual `jackson-databind` module publishes under `tools.jackson.core`, not
`tools.jackson` — the deploy step will fail with a bare `groupId:jackson-databind` guess.)
Hibernate ships `Jackson3JsonFormatMapper` and the older `JacksonJsonFormatMapper` (Jackson 2)
side by side in 7.4.5, so either major version works once present.
## The equals/hashCode HashSet trap — reproduced end to end
Post 4864's Q5 warns against surrogate-id-based `equals()`/`hashCode()`. [`IdBasedEqualsHashSetTrapTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsHashSetTrapTest.java)
builds the actual failure: an entity ([`IdBasedEqualsEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdBasedEqualsEntity.java) / [`IdentityHashSetEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/IdentityHashSetEntity.java)) with `equals()`/`hashCode()` on the `@GeneratedValue` `id`
is added to a `HashSet` while `id` is still `null`, then `persist()`d. The **same reference**,
looked up in the **same** `Set`, comes back `contains() == false` — because the hash code
changed after insertion and `HashSet` is now probing the wrong bucket. Manual iteration with
`equals()` still finds it, confirming it's specifically the hash-bucket indexing that breaks,
not equality itself. (Chapter 06 shows the natural-id-based version of `equals()`/`hashCode()`
does not have this problem — see
[`06 — Natural IDs`](06-natural-ids.md#the-equalshashcode-advice-holds-up--with-one-nuance-the-article-doesnt-mention).)
## Access type mixing: two real, non-obvious symptoms
The article never discusses `@Access`/mixed access at all. [`MixedAccessTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessTest.java) (against [`MixedAccessEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/MixedAccessEntity.java)) reproduces two
things worth knowing:
1. Hibernate's default PROPERTY-access strategy **requires a setter**, even for a logically
read-only derived attribute — omitting one throws `PropertyNotFoundException: Could not locate
setter method for property 'computedLabel'` at boot. A no-op setter is the workaround if the
attribute is truly meant to be read-only.
2. A PROPERTY-access getter with side effects (a call counter, here) is invoked **more than
once per flush** by Hibernate (2 calls observed for one insert) — once for dirty-check
comparison, once for the actual write. Any "just compute it in the getter" derived
PROPERTY-access attribute pays that cost on every flush, not once per logical read.
## What's actually new in Jakarta Persistence 3.2 (verified via `javap` on `jakarta.persistence-api-3.2.0.jar`)
Three things this article predates, each confirmed present in the 3.2.0 jar and exercised in
[`Jpa32NewFeaturesTest`](../src/test/java/com/ankurm/hibernatedemo/persistenceannotations/Jpa32NewFeaturesTest.java):
- **`@EnumeratedValue`** (`@Target(FIELD)` only — cannot go on a getter) lets an enum control its
own persisted representation via a designated field. Tested against [`EnumeratedValueEntity`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/EnumeratedValueEntity.java): a `Priority` enum with a `code`
field (`"L"`/`"M"`/`"H"`) persists that exact string, not the ordinal or `name()` — but it
still needs `@Enumerated(EnumType.STRING)` on the entity field, or boot fails with
`@EnumeratedValue for EnumType.ORDINAL must be placed on a field whose type is byte, short, or
int` (ORDINAL is still JPA's overall default even when `@EnumeratedValue` is present).
- **`TypedQuery.getSingleResultOrNull()`** returns `null` for a zero-row match instead of
throwing `NoResultException` — confirmed via `javap` on `jakarta.persistence.TypedQuery` and
exercised directly. (Chapter 14 exercises the same method on a plain `Query` — see
[`14 — Named queries`](14-named-queries.md#getsingleresultornull-vs-getsingleresult).)
- **JPQL constructor expressions targeting a Java `record`** work: `select new
com.example.PriorityCountView(e.priority, count(e)) from ... group by e.priority` populates a
`record` [`PriorityCountView(Priority priority, long total)`](../src/main/java/com/ankurm/hibernatedemo/persistenceannotations/PriorityCountView.java) via its canonical constructor,
matched positionally, exactly like a regular DTO class would have been pre-3.2.
## Numbers
| Test | Result | Source |
|---|---|---|
| `@Temporal` on `LocalDate` | boots + round-trips; 1 WARN log line per field | `persistenceannotations-tests.txt` |
| Enum ORDINAL reorder | stored ordinal 1 resolves to wrong constant, no exception | `persistenceannotations-tests.txt` |
| `@JdbcTypeCode(JSON)` on H2 | works once `tools.jackson.core:jackson-databind` present; H2 column type = `JSON` | `persistenceannotations-tests.txt` |
| Surrogate-id equals in HashSet | `contains()` false after persist, same reference | `persistenceannotations-tests.txt` |
| Mixed access getter | called 2x per flush | `persistenceannotations-tests.txt` |
| `@EnumeratedValue` | persists `"H"` not ordinal `2` or name `"HIGH"` | `persistenceannotations-tests.txt` |
The exact failure, reproduced with every JSON provider stripped off the classpath, is captured in [`docs/output/persistenceannotations-json-no-formatmapper.txt`](output/persistenceannotations-json-no-formatmapper.txt) &mdash; `spring-boot-starter-data-jpa` alone does not bring one.
[← Previous: 04 — Annotations vs. XML mappings](04-annotations-vs-xml.md) | [Next: 06 — Natural IDs →](06-natural-ids.md)