simplify handling of datetimes and fix conversion to JDBC timezone

This commit is contained in:
Gavin King 2024-11-19 20:49:12 +01:00
parent 1b9f636114
commit 849f23c9c1
5 changed files with 136 additions and 177 deletions

View File

@ -9,6 +9,9 @@ import org.hibernate.engine.jdbc.LobCreator;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.TimeZone;
/**
@ -91,4 +94,53 @@ public interface WrapperOptions {
* @see org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE
*/
TimeZone getJdbcTimeZone();
/**
* Get the {@link ZoneId} representing the
* {@linkplain #getJdbcTimeZone JDBC time zone}, or the
* {@linkplain ZoneId#systemDefault JVM default time zone id}
* if no JDBC time zone was set via
* {@value org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE}.
*
* @apiNote Use for converting datetimes to and from the JDBC
* time zone.
*
* @see org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE
*/
default ZoneId getJdbcZoneId() {
final TimeZone jdbcTimeZone = getJdbcTimeZone();
return jdbcTimeZone == null
? ZoneId.systemDefault()
: jdbcTimeZone.toZoneId();
}
/**
* Get the current {@link ZoneOffset} for the {@link ZoneId}
* representing the {@linkplain #getJdbcTimeZone JDBC time zone},
* or the {@linkplain ZoneId#systemDefault JVM default time zone id}
* if no JDBC time zone was set via
* {@value org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE}.
*
* @apiNote Use for converting <em>times</em> to and from the JDBC
* time zone. For datetimes, prefer {@link #getJdbcZoneId()}
* to apply the rules in force at the given datetime.
*
* @see org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE
*/
default ZoneOffset getJdbcZoneOffset() {
return getJdbcZoneId().getRules().getOffset( Instant.now() );
}
/**
* Get the current {@link ZoneOffset} for the
* {@linkplain ZoneId#systemDefault JVM default time zone id}.
*
* @apiNote Use for converting <em>times</em> to and from the
* system time zone. For datetimes, prefer
* {@link ZoneId#systemDefault()}, to apply the rules
* in force at the given datetime.
*/
default ZoneOffset getSystemZoneOffset() {
return ZoneId.systemDefault().getRules().getOffset( Instant.now() );
}
}

View File

@ -7,7 +7,6 @@ package org.hibernate.type.descriptor.java;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
@ -102,23 +101,10 @@ public class InstantJavaType extends AbstractTemporalJavaType<Instant>
}
if ( Timestamp.class.isAssignableFrom( type ) ) {
/*
* This works around two bugs:
* - HHH-13266 (JDK-8061577): around and before 1900,
* the number of milliseconds since the epoch does not mean the same thing
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
* - around 1905, both methods are equally valid, so we don't really care which one is used.
*/
ZonedDateTime zonedDateTime = instant.atZone( ZoneId.systemDefault() );
if ( zonedDateTime.getYear() < 1905 ) {
return (X) Timestamp.valueOf( zonedDateTime.toLocalDateTime() );
}
else {
return (X) Timestamp.from( instant );
}
// NOTE: do not use Timestamp.from(Instant) because that returns a Timestamp in UTC
final ZonedDateTime dateTime =
instant.atZone( options.getJdbcZoneId() ); // convert to the JDBC timezone
return (X) Timestamp.valueOf( dateTime.toLocalDateTime() );
}
if ( java.sql.Date.class.isAssignableFrom( type ) ) {
@ -155,22 +141,10 @@ public class InstantJavaType extends AbstractTemporalJavaType<Instant>
}
if ( value instanceof Timestamp timestamp ) {
/*
* This works around two bugs:
* - HHH-13266 (JDK-8061577): around and before 1900,
* the number of milliseconds since the epoch does not mean the same thing
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
* - around 1905, both methods are equally valid, so we don't really care which one is used.
*/
if ( timestamp.getYear() < 5 ) { // Timestamp year 0 is 1900
return timestamp.toLocalDateTime().atZone( ZoneId.systemDefault() ).toInstant();
}
else {
return timestamp.toInstant();
}
// NOTE: do not use Timestamp.getInstant() because that assumes the Timestamp is in UTC
return timestamp.toLocalDateTime()
.atZone( options.getJdbcZoneId() ) // the Timestamp is in the JDBC timezone
.toInstant(); // return the instant (always in UTC)
}
if ( value instanceof Long longValue ) {

View File

@ -137,24 +137,9 @@ public class OffsetDateTimeJavaType extends AbstractTemporalJavaType<OffsetDateT
}
if ( Timestamp.class.isAssignableFrom( type ) ) {
/*
* This works around two bugs:
* - HHH-13266 (JDK-8061577): around and before 1900,
* the number of milliseconds since the epoch does not mean the same thing
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
* - around 1905, both methods are equally valid, so we don't really care which one is used.
*/
if ( offsetDateTime.getYear() < 1905 ) {
return (X) Timestamp.valueOf(
offsetDateTime.atZoneSameInstant( ZoneId.systemDefault() ).toLocalDateTime()
);
}
else {
return (X) Timestamp.from( offsetDateTime.toInstant() );
}
final ZonedDateTime dateTime =
offsetDateTime.atZoneSameInstant( options.getJdbcZoneId() ); // convert to the JDBC timezone
return (X) Timestamp.valueOf( dateTime.toLocalDateTime() );
}
if ( java.sql.Date.class.isAssignableFrom( type ) ) {
@ -195,22 +180,10 @@ public class OffsetDateTimeJavaType extends AbstractTemporalJavaType<OffsetDateT
}
if (value instanceof Timestamp timestamp) {
/*
* This works around two bugs:
* - HHH-13266 (JDK-8061577): around and before 1900,
* the number of milliseconds since the epoch does not mean the same thing
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
* - around 1905, both methods are equally valid, so we don't really care which one is used.
*/
if ( timestamp.getYear() < 5 ) { // Timestamp year 0 is 1900
return timestamp.toLocalDateTime().atZone( ZoneId.systemDefault() ).toOffsetDateTime();
}
else {
return OffsetDateTime.ofInstant( timestamp.toInstant(), ZoneId.systemDefault() );
}
return timestamp.toLocalDateTime()
.atZone( options.getJdbcZoneId() ) // the Timestamp is in the JDBC timezone
.withZoneSameInstant( ZoneId.systemDefault() ) // convert back to the VM timezone
.toOffsetDateTime(); // return the corresponding OffsetDateTime
}
if (value instanceof Date date) {

View File

@ -11,7 +11,7 @@ import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZoneOffset;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoField;
import java.util.Calendar;
@ -92,59 +92,71 @@ public class OffsetTimeJavaType extends AbstractTemporalJavaType<OffsetTime> {
}
if ( LocalTime.class.isAssignableFrom( type ) ) {
return (X) offsetTime.withOffsetSameInstant( getCurrentSystemOffset() ).toLocalTime();
return (X) offsetTime.withOffsetSameInstant( options.getSystemZoneOffset() ).toLocalTime();
}
if ( OffsetDateTime.class.isAssignableFrom( type ) ) {
return (X) offsetTime.atDate( LocalDate.EPOCH );
return (X) offsetDateTimeAtEpoch( offsetTime );
}
// for legacy types, we assume that the JDBC timezone is passed to JDBC
// (since PS.setTime() and friends do accept a timezone passed as a Calendar)
final OffsetTime jdbcOffsetTime = offsetTime.withOffsetSameInstant( getCurrentJdbcOffset(options) );
if ( Time.class.isAssignableFrom( type ) ) {
// Use ZoneOffset rather than ZoneId in the conversion,
// since the offset for a zone varies over time, but
// a Time does not have an attached Date.
final OffsetTime jdbcOffsetTime =
offsetTime.withOffsetSameInstant( options.getJdbcZoneOffset() ); // convert to the JDBC timezone
final Time time = Time.valueOf( jdbcOffsetTime.toLocalTime() );
if ( jdbcOffsetTime.getNano() == 0 ) {
return (X) time;
// Time.valueOf() throws away milliseconds
return (X) withMillis( jdbcOffsetTime, time );
}
// Preserve milliseconds, which java.sql.Time supports
return (X) new Time( time.getTime() + DateTimeUtils.roundToPrecision( jdbcOffsetTime.getNano(), 3 ) / 1000000 );
}
final OffsetDateTime jdbcOffsetDateTime = jdbcOffsetTime.atDate( LocalDate.EPOCH );
if ( Timestamp.class.isAssignableFrom( type ) ) {
/*
* Workaround for HHH-13266 (JDK-8061577).
* Ideally we'd want to use Timestamp.from( jdbcOffsetDateTime.toInstant() ),
* but this won't always work since Timestamp.from() assumes the number of
* milliseconds since the epoch means the same thing in Timestamp and Instant,
* but it doesn't, in particular before 1900.
*/
return (X) Timestamp.valueOf( jdbcOffsetDateTime.toLocalDateTime() );
final OffsetTime jdbcOffsetTime =
offsetTime.withOffsetSameInstant( options.getJdbcZoneOffset() ); // convert to the JDBC timezone
return (X) Timestamp.valueOf( offsetDateTimeAtEpoch( jdbcOffsetTime ).toLocalDateTime() );
}
if ( Calendar.class.isAssignableFrom( type ) ) {
return (X) GregorianCalendar.from( jdbcOffsetDateTime.toZonedDateTime() );
return (X) GregorianCalendar.from( offsetDateTimeAtEpoch( offsetTime ).toZonedDateTime() );
}
// for instants, we assume that the JDBC timezone, if any, is ignored
final Instant instant = offsetTime.atDate( LocalDate.EPOCH ).toInstant();
if ( Long.class.isAssignableFrom( type ) ) {
return (X) Long.valueOf( instant.toEpochMilli() );
return (X) Long.valueOf( instantAtEpoch( offsetTime ).toEpochMilli() );
}
if ( Date.class.isAssignableFrom( type ) ) {
return (X) Date.from( instant );
return (X) Date.from( instantAtEpoch( offsetTime ) );
}
throw unknownUnwrap( type );
}
private static Time withMillis(OffsetTime jdbcOffsetTime, Time time) {
final int nanos = jdbcOffsetTime.getNano();
if ( nanos == 0 ) {
return time;
}
else {
// Preserve milliseconds, which java.sql.Time supports
final long millis = DateTimeUtils.roundToPrecision( nanos, 3 ) / 1_000_000;
return new Time( time.getTime() + millis );
}
}
private static OffsetDateTime offsetDateTimeAtEpoch(OffsetTime jdbcOffsetTime) {
return jdbcOffsetTime.atDate( LocalDate.EPOCH );
}
private static Instant instantAtEpoch(OffsetTime offsetTime) {
return offsetDateTimeAtEpoch( offsetTime ).toInstant();
}
@Override
public <X> OffsetTime wrap(X value, WrapperOptions options) {
if ( value == null ) {
@ -159,67 +171,39 @@ public class OffsetTimeJavaType extends AbstractTemporalJavaType<OffsetTime> {
}
if (value instanceof LocalTime localTime) {
return localTime.atOffset( getCurrentSystemOffset() );
return localTime.atOffset( options.getSystemZoneOffset() );
}
if ( value instanceof OffsetDateTime offsetDateTime) {
return offsetDateTime.toOffsetTime();
}
/*
* Also, in order to fix HHH-13357, and to be consistent with the conversion to Time (see above),
* we set the offset to the current offset of the JVM (OffsetDateTime.now().getOffset()).
* This is different from setting the *zone* to the current *zone* of the JVM (ZoneId.systemDefault()),
* since a zone has a varying offset over time,
* thus the zone might have a different offset for the given timezone than it has for the current date/time.
* For example, if the timestamp represents 1970-01-01TXX:YY,
* and the JVM is set to use Europe/Paris as a timezone, and the current time is 2019-04-16-08:53,
* then applying the JVM timezone to the timestamp would result in the offset +01:00,
* but applying the JVM offset would result in the offset +02:00, since DST is in effect at 2019-04-16-08:53.
*
* Of course none of this would be a problem if we just stored the offset in the database,
* but I guess there are historical reasons that explain why we don't.
*/
// for legacy types, we assume that the JDBC timezone is passed to JDBC
// (since PS.setTime() and friends do accept a timezone passed as a Calendar)
if (value instanceof Time time) {
final OffsetTime offsetTime = time.toLocalTime()
.atOffset( getCurrentJdbcOffset( options) )
.withOffsetSameInstant( getCurrentSystemOffset() );
long millis = time.getTime() % 1000;
if ( millis == 0 ) {
return offsetTime;
}
if ( millis < 0 ) {
// The milliseconds for a Time could be negative,
// which usually means the time is in a different time zone
millis += 1_000L;
}
return offsetTime.with( ChronoField.NANO_OF_SECOND, millis * 1_000_000L );
// Use ZoneOffset rather than ZoneId in the conversion,
// since the offset for a zone varies over time, but
// a Time does not have an attached Date.
final OffsetTime offsetTime =
time.toLocalTime().atOffset( options.getJdbcZoneOffset() ) // the Timestamp is in the current JDBC timezone offset
.withOffsetSameInstant( options.getSystemZoneOffset() ); // convert back to the VM timezone
// Time.toLocalTime() strips off nanos
return withNanos( time, offsetTime );
}
if (value instanceof Timestamp timestamp) {
/*
* Workaround for HHH-13266 (JDK-8061577).
* Ideally we'd want to use OffsetDateTime.ofInstant( ts.toInstant(), ... ),
* but this won't always work since ts.toInstant() assumes the number of
* milliseconds since the epoch means the same thing in Timestamp and Instant,
* but it doesn't, in particular before 1900.
*/
return timestamp.toLocalDateTime().toLocalTime().atOffset( getCurrentJdbcOffset(options) )
.withOffsetSameInstant( getCurrentSystemOffset() );
return timestamp.toLocalDateTime()
.atZone( options.getJdbcZoneId() ) // the Timestamp is in the JDBC timezone
.withZoneSameInstant( ZoneId.systemDefault() ) // convert back to the VM timezone
.toOffsetDateTime().toOffsetTime(); // return the time part
}
if (value instanceof Date date) {
return OffsetTime.ofInstant( date.toInstant(), getCurrentSystemOffset() );
return OffsetTime.ofInstant( date.toInstant(), options.getSystemZoneOffset() );
}
// for instants, we assume that the JDBC timezone, if any, is ignored
if (value instanceof Long millis) {
return OffsetTime.ofInstant( Instant.ofEpochMilli(millis), getCurrentSystemOffset() );
return OffsetTime.ofInstant( Instant.ofEpochMilli(millis), options.getSystemZoneOffset() );
}
if (value instanceof Calendar calendar) {
@ -229,17 +213,21 @@ public class OffsetTimeJavaType extends AbstractTemporalJavaType<OffsetTime> {
throw unknownWrap( value.getClass() );
}
private static ZoneOffset getCurrentJdbcOffset(WrapperOptions options) {
if ( options.getJdbcTimeZone() != null ) {
return OffsetDateTime.now().atZoneSameInstant( options.getJdbcTimeZone().toZoneId() ).getOffset();
private static OffsetTime withNanos(Time time, OffsetTime offsetTime) {
final long millis = time.getTime() % 1000;
final long nanos;
if ( millis == 0 ) {
return offsetTime;
}
else if ( millis < 0 ) {
// The milliseconds for a Time could be negative,
// which usually means the time is in a different time zone
nanos = (millis + 1_000L) * 1_000_000L;
}
else {
return getCurrentSystemOffset();
nanos = millis * 1_000_000L;
}
}
private static ZoneOffset getCurrentSystemOffset() {
return OffsetDateTime.now().getOffset();
return offsetTime.with( ChronoField.NANO_OF_SECOND, nanos );
}
@Override

View File

@ -99,24 +99,9 @@ public class ZonedDateTimeJavaType extends AbstractTemporalJavaType<ZonedDateTim
}
if ( Timestamp.class.isAssignableFrom( type ) ) {
/*
* This works around two bugs:
* - HHH-13266 (JDK-8061577): around and before 1900,
* the number of milliseconds since the epoch does not mean the same thing
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
* - around 1905, both methods are equally valid, so we don't really care which one is used.
*/
if ( zonedDateTime.getYear() < 1905 ) {
return (X) Timestamp.valueOf(
zonedDateTime.withZoneSameInstant( ZoneId.systemDefault() ).toLocalDateTime()
);
}
else {
return (X) Timestamp.from( zonedDateTime.toInstant() );
}
final ZonedDateTime dateTime =
zonedDateTime.withZoneSameInstant( options.getJdbcZoneId() ); // convert to the JDBC timezone
return (X) Timestamp.valueOf( dateTime.toLocalDateTime() );
}
if ( java.sql.Date.class.isAssignableFrom( type ) ) {
@ -157,22 +142,9 @@ public class ZonedDateTimeJavaType extends AbstractTemporalJavaType<ZonedDateTim
}
if (value instanceof Timestamp timestamp) {
/*
* This works around two bugs:
* - HHH-13266 (JDK-8061577): around and before 1900,
* the number of milliseconds since the epoch does not mean the same thing
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
* - around 1905, both methods are equally valid, so we don't really care which one is used.
*/
if ( timestamp.getYear() < 5 ) { // Timestamp year 0 is 1900
return timestamp.toLocalDateTime().atZone( ZoneId.systemDefault() );
}
else {
return timestamp.toInstant().atZone( ZoneId.systemDefault() );
}
return timestamp.toLocalDateTime()
.atZone( options.getJdbcZoneId() ) // the Timestamp is in the JDBC timezone
.withZoneSameInstant( ZoneId.systemDefault() ); // convert back to the VM timezone
}
if (value instanceof Date date) {