Files
hibernate-demo/docs/13-date-and-time-mapping.md

205 lines
12 KiB
Markdown
Executable File

# 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 &mdash; 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)