10 KiB
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 | Next: 06 — Natural IDs →
Backs ankurm.com: mastering JPA persistence annotations in Hibernate 7.
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 and
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 shows Hibernate logs a WARN at boot for every such field, using TemporalOnLocalDateEntity:
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.)
@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 does: persist a row with OrderStatus.SHIPPED
(ordinal 1 in the original 3-constant enum, EnumDefaultOrdinalEntity), then read the same physical row back through a
second entity/enum pair (EnumReorderedV2Entity) 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 in JsonColumnOnH2Test:
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:
<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
builds the actual failure: an entity (IdBasedEqualsEntity / IdentityHashSetEntity) 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.)
Access type mixing: two real, non-obvious symptoms
The article never discusses @Access/mixed access at all. MixedAccessTest (against MixedAccessEntity) reproduces two
things worth knowing:
- 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. - 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:
@EnumeratedValue(@Target(FIELD)only — cannot go on a getter) lets an enum control its own persisted representation via a designated field. Tested againstEnumeratedValueEntity: aPriorityenum with acodefield ("L"/"M"/"H") persists that exact string, not the ordinal orname()— 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@EnumeratedValueis present).TypedQuery.getSingleResultOrNull()returnsnullfor a zero-row match instead of throwingNoResultException— confirmed viajavaponjakarta.persistence.TypedQueryand exercised directly. (Chapter 14 exercises the same method on a plainQuery— see14 — Named queries.)- JPQL constructor expressions targeting a Java
recordwork:select new com.example.PriorityCountView(e.priority, count(e)) from ... group by e.prioritypopulates arecordPriorityCountView(Priority priority, long total)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 — spring-boot-starter-data-jpa alone does not bring one.
← Previous: 04 — Annotations vs. XML mappings | Next: 06 — Natural IDs →