# 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 tools.jackson.core jackson-databind 3.1.5 ``` (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)