Files
hibernate-demo/docs/05-jpa-persistence-annotations.md
T

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:

  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:

  • @EnumeratedValue (@Target(FIELD) only — cannot go on a getter) lets an enum control its own persisted representation via a designated field. Tested against EnumeratedValueEntity: 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.)
  • 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) 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 →