205 lines
12 KiB
Markdown
Executable File
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 — 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)
|