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
+204
@@ -0,0 +1,204 @@
|
||||
# 13 — Date and time mapping: what actually round-trips
|
||||
|
||||
[← Previous: 12 — Association mappings](12-association-mappings.md) | [Next: 14 — Named queries →](14-named-queries.md)
|
||||
|
||||
Backs [ankurm.com: Hibernate 7 date/time mapping](https://ankurm.com/mastering-hibernate-7-date-time-mapping-java-time-timezones-and-jdbc-4-2/).
|
||||
|
||||
Everything below comes from a JUnit test in [`src/test/java/com/ankurm/hibernatedemo/datetime/`](../src/test/java/com/ankurm/hibernatedemo/datetime/)
|
||||
against Hibernate 7.4.5.Final, run on H2 2.4.240 (and HSQLDB 2.7.3 where noted), Java 25. The
|
||||
sandbox JVM's own default time zone during these runs was `Asia/Calcutta` (+05:30) unless a test
|
||||
explicitly overrides `-Duser.timezone`.
|
||||
|
||||
## Basic temporal types round trip
|
||||
|
||||
[`TemporalTypesEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TemporalTypesEntity.java) maps every basic temporal type in one entity. Generated DDL
|
||||
([`BasicTemporalTypesTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/BasicTemporalTypesTest.java), H2 2.4.240):
|
||||
|
||||
```
|
||||
create table temporal_types (
|
||||
id bigint generated by default as identity,
|
||||
instant timestamp(6) with time zone,
|
||||
legacy_calendar timestamp(6),
|
||||
legacy_date_as_date date,
|
||||
legacy_date_as_timestamp timestamp(6),
|
||||
legacy_date_no_temporal timestamp(6),
|
||||
local_date date,
|
||||
local_date_time timestamp(6),
|
||||
local_time time(0),
|
||||
offset_date_time timestamp(6) with time zone,
|
||||
zoned_date_time timestamp(6) with time zone,
|
||||
primary key (id)
|
||||
)
|
||||
```
|
||||
|
||||
Round trip of every value came back correct ([`docs/output/datetime-basic-types.txt`](output/datetime-basic-types.txt)). One
|
||||
finding worth flagging: `legacyDateNoTemporal` is a `java.util.Date` field with **no**
|
||||
`@Temporal` annotation at all. It did not fail to bootstrap and did not throw -- Hibernate 7.4.5
|
||||
defaulted it to a `TIMESTAMP` column and round-tripped it correctly. The old JPA-provider
|
||||
requirement that `@Temporal` is mandatory on `Date`/`Calendar` fields does not hold here.
|
||||
|
||||
## `@Temporal` verified deprecated, and verified harmless when misapplied
|
||||
|
||||
```
|
||||
$ javap -v jakarta.persistence.Temporal # from jakarta.persistence-api-3.2.0.jar
|
||||
Deprecated: true
|
||||
RuntimeVisibleAnnotations:
|
||||
java.lang.Deprecated(since="3.2")
|
||||
```
|
||||
|
||||
`@Temporal` is formally deprecated since Jakarta Persistence 3.2 -- confirmed by bytecode
|
||||
inspection, not the javadoc prose. Chapter 05 confirms the identical boot-time warning on a
|
||||
`LocalDate` field; this chapter's [`TemporalOnJavaTimeEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TemporalOnJavaTimeEntity.java) puts it on an `Instant` field instead
|
||||
-- see [`05 — JPA persistence annotations`](05-jpa-persistence-annotations.md#temporal-is-formally-deprecatedsince--32--and-it-is-a-silent-no-op-not-silent-silent)
|
||||
for the `LocalDate` case.
|
||||
|
||||
Using it anyway on `java.time` fields (`java.util.Date`/`Calendar` are its only legal targets)
|
||||
does not break anything in Hibernate 7.4.5. [`TemporalAnnotationTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/TemporalAnnotationTest.java) puts
|
||||
`@Temporal(TemporalType.TIMESTAMP)` on an `Instant` field: the application context boots, and the
|
||||
value round-trips exactly. The framework logs a deprecation warning at boot
|
||||
(`HHH90000033: Encountered use of deprecated annotation ... at ...instantWithTemporalAnnotation`)
|
||||
but does not reject it. See [`docs/output/datetime-temporal-annotation.txt`](output/datetime-temporal-annotation.txt).
|
||||
|
||||
## The central experiment: `@TimeZoneStorage`
|
||||
|
||||
```
|
||||
$ javap org.hibernate.annotations.TimeZoneStorageType # hibernate-core-7.4.5.Final.jar
|
||||
NATIVE, NORMALIZE, NORMALIZE_UTC, COLUMN, AUTO, DEFAULT
|
||||
```
|
||||
|
||||
Six constants, not five -- `DEFAULT` is a real enum member (a sentinel meaning "consult
|
||||
`hibernate.timezone.default_storage`"), separate from the five storage strategies.
|
||||
|
||||
**What "default" resolves to.** `hibernate.timezone.default_storage` (confirmed present as
|
||||
`org.hibernate.cfg.MappingSettings.TIMEZONE_DEFAULT_STORAGE`) defaults, when unset, to
|
||||
`TimeZoneStorageType.DEFAULT` itself -- a second layer of indirection resolved by
|
||||
`MetadataBuildingOptions.getDefaultTimeZoneStorage()`, which asks the current SQL **Dialect**
|
||||
for its `TimeZoneSupport` and converts that into a storage strategy (confirmed by
|
||||
decompiling `MetadataBuilderImpl` and `TimeZoneStorageHelper` in hibernate-core 7.4.5.Final --
|
||||
not from a blog post). In practice, on H2 (which has native `TIMESTAMP WITH TIME ZONE` support),
|
||||
a column with **no** `@TimeZoneStorage` annotation at all ([`TimeZoneStorageEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageEntity.java)) behaved identically to `NATIVE` in
|
||||
every test below, per [`TimeZoneStorageTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/TimeZoneStorageTest.java).
|
||||
|
||||
**Storing `+05:30` and reading it back**, under the JVM's own default zone (`Asia/Calcutta`,
|
||||
itself `+05:30` -- chosen deliberately as a first baseline where the JVM zone matches the data):
|
||||
|
||||
```
|
||||
TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30
|
||||
TZ_MODE NATIVE = 2026-06-15T14:00+05:30
|
||||
TZ_MODE NORMALIZE = 2026-06-15T14:00+05:30
|
||||
TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z
|
||||
TZ_MODE COLUMN = 2026-06-15T14:00+05:30
|
||||
TZ_MODE AUTO = 2026-06-15T14:00+05:30
|
||||
```
|
||||
|
||||
That alone doesn't show much -- `NORMALIZE` had nothing to normalize *to* since the JVM zone
|
||||
already matched. Rerunning the identical test with `-Duser.timezone=America/New_York` (JVM
|
||||
default zone changed, database untouched) is where the real behavior shows up:
|
||||
|
||||
```
|
||||
TZ_MODE no-annotation (default) = 2026-06-15T14:00+05:30 <- unchanged
|
||||
TZ_MODE NATIVE = 2026-06-15T14:00+05:30 <- unchanged
|
||||
TZ_MODE NORMALIZE = 2026-06-15T04:30-04:00 <- CHANGED: re-expressed in JVM's zone
|
||||
TZ_MODE NORMALIZE_UTC = 2026-06-15T08:30Z <- unchanged (always UTC)
|
||||
TZ_MODE COLUMN = 2026-06-15T14:00+05:30 <- unchanged
|
||||
TZ_MODE AUTO = 2026-06-15T14:00+05:30 <- unchanged
|
||||
```
|
||||
|
||||
Both readings represent the exact same instant (`2026-06-15T08:30:00Z`); only the *displayed*
|
||||
offset for `NORMALIZE` moved, because `NORMALIZE` explicitly re-expresses the stored value in
|
||||
whatever the JVM's current default zone is at read time. Every other mode is immune to a change
|
||||
in the JVM's default time zone -- **this is the JVM-default-timezone hazard, made concrete**: if
|
||||
your fleet ever runs with inconsistent `user.timezone` settings (a classic container migration
|
||||
issue), `NORMALIZE` is the one mode that will show you a different offset for identical data
|
||||
depending on which box read it. `NATIVE`, `COLUMN`, `AUTO`, and Hibernate's default all store and
|
||||
return the exact offset supplied, immune to the reading JVM's zone.
|
||||
|
||||
**`COLUMN` mode's DDL** does add a second column, exactly as advertised:
|
||||
|
||||
```
|
||||
create table tz_storage (
|
||||
...
|
||||
column_mode_col timestamp(6) with time zone,
|
||||
column_mode_col_tz integer,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
(On HSQLDB, which lacks a native `WITH TIME ZONE` timestamp type for every mode, `AUTO` also
|
||||
picks up a companion `_tz integer` column -- confirming `AUTO`'s behavior is dialect-dependent,
|
||||
consistent with the `TimeZoneSupport`-driven resolution above.)
|
||||
|
||||
Raw output: [`docs/output/datetime-timezone-storage-default-jvm.txt`](output/datetime-timezone-storage-default-jvm.txt),
|
||||
[`docs/output/datetime-timezone-storage-nydefault.txt`](output/datetime-timezone-storage-nydefault.txt).
|
||||
|
||||
## Second-precision / truncation
|
||||
|
||||
Stored `LocalDateTime`/`Instant` ([`NanoPrecisionEntity`](../src/main/java/com/ankurm/hibernatedemo/datetime/NanoPrecisionEntity.java)) with `123456789` ns and read back, via
|
||||
[`NanosecondTruncationTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationTest.java) (H2) and [`NanosecondTruncationHsqldbTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/NanosecondTruncationHsqldbTest.java) (HSQLDB):
|
||||
|
||||
| Database | Nanos in | Nanos out | Behavior |
|
||||
|---|---|---|---|
|
||||
| H2 2.4.240 | 123456789 | **123457000** | Rounds to microsecond precision |
|
||||
| HSQLDB 2.7.3 | 123456789 | **123456000** | Truncates to microsecond precision |
|
||||
|
||||
Same input, two different databases, two different results -- H2 rounds the last three digits
|
||||
away, HSQLDB drops them. Neither preserves true nanosecond precision (both cap at `timestamp(6)`,
|
||||
i.e. microseconds), but "rounds" vs "truncates" is a real, silent, database-specific behavior
|
||||
difference that can shift a stored value by up to half a microsecond depending on which engine is
|
||||
under the app.
|
||||
|
||||
**Correction**: `@Column(precision = 9)` on the temporal field had **zero effect** on the
|
||||
generated DDL or the stored precision in this experiment -- the column type stayed
|
||||
`timestamp(6)` regardless, on both databases. JPA's `precision`/`scale` `@Column` attributes are
|
||||
defined for **numeric** (`DECIMAL`) columns; they do not control fractional-second digits on a
|
||||
temporal column in Hibernate 7.4.5. A common piece of blog advice ("use
|
||||
`@Column(precision = 6)` to force microsecond storage") does not do anything here -- the
|
||||
precision was already fixed at 6 by the dialect's default temporal column type, with or without
|
||||
the annotation.
|
||||
|
||||
Raw output: [`docs/output/datetime-nanosecond-h2.txt`](output/datetime-nanosecond-h2.txt), [`docs/output/datetime-nanosecond-hsqldb.txt`](output/datetime-nanosecond-hsqldb.txt).
|
||||
|
||||
## `hibernate.jdbc.time_zone`
|
||||
|
||||
Confirmed to exist as `org.hibernate.cfg.JdbcSettings.JDBC_TIME_ZONE`. Setting it to
|
||||
`America/New_York` (JVM default left at `Asia/Calcutta`) and inspecting the **raw** stored value
|
||||
via a native `CAST(... AS VARCHAR)` query, in [`JdbcTimeZoneTest`](../src/test/java/com/ankurm/hibernatedemo/datetime/JdbcTimeZoneTest.java):
|
||||
|
||||
```
|
||||
original LocalDateTime = 2026-07-04T09:00
|
||||
raw DB value for LocalDateTime column = 2026-07-03 23:30:00 <- shifted!
|
||||
round-tripped LocalDateTime = 2026-07-04T09:00 <- but reads back correctly
|
||||
|
||||
original OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30
|
||||
raw DB value for NATIVE offset column = 2026-07-04 09:00:00+05:30 <- unchanged
|
||||
round-tripped OffsetDateTime (NATIVE) = 2026-07-04T09:00+05:30
|
||||
```
|
||||
|
||||
`hibernate.jdbc.time_zone` converts the wall-clock value of a zone-less `LocalDateTime` into the
|
||||
configured zone before it is bound to the JDBC driver -- the literal bytes stored in the database
|
||||
shift, even though the application-level round trip through the *same* Hibernate configuration is
|
||||
transparent (you get your `LocalDateTime` back unchanged). The danger is exactly the
|
||||
"looks fine in the app, wrong when another tool reads the table directly" class of bug. It has
|
||||
**no effect** on a value that already carries an explicit offset (`OffsetDateTime` with
|
||||
`TimeZoneStorage.NATIVE`) -- that value is bound and stored exactly as given, confirmed by both
|
||||
the raw column value and the round trip being unchanged.
|
||||
|
||||
Raw output: [`docs/output/datetime-jdbc-time-zone.txt`](output/datetime-jdbc-time-zone.txt).
|
||||
|
||||
## Summary
|
||||
|
||||
| Claim | Verified value |
|
||||
|---|---|
|
||||
| `@Temporal` deprecated since | Jakarta Persistence 3.2 (confirmed via `javap -v`) |
|
||||
| `@Temporal` misapplied to `java.time` | Logs a deprecation warning, does not fail |
|
||||
| `TimeZoneStorageType` constants | NATIVE, NORMALIZE, NORMALIZE_UTC, COLUMN, AUTO, DEFAULT (6, not 5) |
|
||||
| Hibernate 7.4.5 default storage on H2 | Behaves like NATIVE (dialect-derived, not a fixed constant) |
|
||||
| JVM-zone hazard | Only `NORMALIZE` changes displayed offset when JVM zone changes |
|
||||
| `COLUMN` mode DDL | Adds a companion `_tz integer` column |
|
||||
| Nanosecond round trip | H2 rounds to microseconds; HSQLDB truncates to microseconds |
|
||||
| `@Column(precision=9)` on a temporal field | No effect on generated DDL or stored precision |
|
||||
| `hibernate.jdbc.time_zone` | Shifts raw stored value for zone-less types; no effect on explicit-offset types |
|
||||
|
||||
`@Temporal`'s deprecation is visible in the class file itself — see [`docs/output/datetime-javap-temporal-deprecated.txt`](output/datetime-javap-temporal-deprecated.txt).
|
||||
|
||||
[← Previous: 12 — Association mappings](12-association-mappings.md) | [Next: 14 — Named queries →](14-named-queries.md)
|
||||
Reference in New Issue
Block a user