140 lines
10 KiB
Markdown
Executable File
140 lines
10 KiB
Markdown
Executable File
# 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)
|