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
+139
@@ -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) — `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)
|
||||
Reference in New Issue
Block a user