Skip to content

Commit 849f23c

Browse files
committed
simplify handling of datetimes and fix conversion to JDBC timezone
1 parent 1b9f636 commit 849f23c

File tree

5 files changed

+136
-177
lines changed

5 files changed

+136
-177
lines changed

hibernate-core/src/main/java/org/hibernate/type/descriptor/WrapperOptions.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
import org.hibernate.engine.spi.SessionFactoryImplementor;
1010
import org.hibernate.engine.spi.SharedSessionContractImplementor;
1111

12+
import java.time.Instant;
13+
import java.time.ZoneId;
14+
import java.time.ZoneOffset;
1215
import java.util.TimeZone;
1316

1417
/**
@@ -91,4 +94,53 @@ default Dialect getDialect() {
9194
* @see org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE
9295
*/
9396
TimeZone getJdbcTimeZone();
97+
98+
/**
99+
* Get the {@link ZoneId} representing the
100+
* {@linkplain #getJdbcTimeZone JDBC time zone}, or the
101+
* {@linkplain ZoneId#systemDefault JVM default time zone id}
102+
* if no JDBC time zone was set via
103+
* {@value org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE}.
104+
*
105+
* @apiNote Use for converting datetimes to and from the JDBC
106+
* time zone.
107+
*
108+
* @see org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE
109+
*/
110+
default ZoneId getJdbcZoneId() {
111+
final TimeZone jdbcTimeZone = getJdbcTimeZone();
112+
return jdbcTimeZone == null
113+
? ZoneId.systemDefault()
114+
: jdbcTimeZone.toZoneId();
115+
}
116+
117+
/**
118+
* Get the current {@link ZoneOffset} for the {@link ZoneId}
119+
* representing the {@linkplain #getJdbcTimeZone JDBC time zone},
120+
* or the {@linkplain ZoneId#systemDefault JVM default time zone id}
121+
* if no JDBC time zone was set via
122+
* {@value org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE}.
123+
*
124+
* @apiNote Use for converting <em>times</em> to and from the JDBC
125+
* time zone. For datetimes, prefer {@link #getJdbcZoneId()}
126+
* to apply the rules in force at the given datetime.
127+
*
128+
* @see org.hibernate.cfg.AvailableSettings#JDBC_TIME_ZONE
129+
*/
130+
default ZoneOffset getJdbcZoneOffset() {
131+
return getJdbcZoneId().getRules().getOffset( Instant.now() );
132+
}
133+
134+
/**
135+
* Get the current {@link ZoneOffset} for the
136+
* {@linkplain ZoneId#systemDefault JVM default time zone id}.
137+
*
138+
* @apiNote Use for converting <em>times</em> to and from the
139+
* system time zone. For datetimes, prefer
140+
* {@link ZoneId#systemDefault()}, to apply the rules
141+
* in force at the given datetime.
142+
*/
143+
default ZoneOffset getSystemZoneOffset() {
144+
return ZoneId.systemDefault().getRules().getOffset( Instant.now() );
145+
}
94146
}

hibernate-core/src/main/java/org/hibernate/type/descriptor/java/InstantJavaType.java

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import java.sql.Timestamp;
88
import java.time.Instant;
99
import java.time.OffsetDateTime;
10-
import java.time.ZoneId;
1110
import java.time.ZoneOffset;
1211
import java.time.ZonedDateTime;
1312
import java.time.format.DateTimeFormatter;
@@ -102,23 +101,10 @@ public <X> X unwrap(Instant instant, Class<X> type, WrapperOptions options) {
102101
}
103102

104103
if ( Timestamp.class.isAssignableFrom( type ) ) {
105-
/*
106-
* This works around two bugs:
107-
* - HHH-13266 (JDK-8061577): around and before 1900,
108-
* the number of milliseconds since the epoch does not mean the same thing
109-
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
110-
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
111-
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
112-
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
113-
* - around 1905, both methods are equally valid, so we don't really care which one is used.
114-
*/
115-
ZonedDateTime zonedDateTime = instant.atZone( ZoneId.systemDefault() );
116-
if ( zonedDateTime.getYear() < 1905 ) {
117-
return (X) Timestamp.valueOf( zonedDateTime.toLocalDateTime() );
118-
}
119-
else {
120-
return (X) Timestamp.from( instant );
121-
}
104+
// NOTE: do not use Timestamp.from(Instant) because that returns a Timestamp in UTC
105+
final ZonedDateTime dateTime =
106+
instant.atZone( options.getJdbcZoneId() ); // convert to the JDBC timezone
107+
return (X) Timestamp.valueOf( dateTime.toLocalDateTime() );
122108
}
123109

124110
if ( java.sql.Date.class.isAssignableFrom( type ) ) {
@@ -155,22 +141,10 @@ public <X> Instant wrap(X value, WrapperOptions options) {
155141
}
156142

157143
if ( value instanceof Timestamp timestamp ) {
158-
/*
159-
* This works around two bugs:
160-
* - HHH-13266 (JDK-8061577): around and before 1900,
161-
* the number of milliseconds since the epoch does not mean the same thing
162-
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
163-
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
164-
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
165-
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
166-
* - around 1905, both methods are equally valid, so we don't really care which one is used.
167-
*/
168-
if ( timestamp.getYear() < 5 ) { // Timestamp year 0 is 1900
169-
return timestamp.toLocalDateTime().atZone( ZoneId.systemDefault() ).toInstant();
170-
}
171-
else {
172-
return timestamp.toInstant();
173-
}
144+
// NOTE: do not use Timestamp.getInstant() because that assumes the Timestamp is in UTC
145+
return timestamp.toLocalDateTime()
146+
.atZone( options.getJdbcZoneId() ) // the Timestamp is in the JDBC timezone
147+
.toInstant(); // return the instant (always in UTC)
174148
}
175149

176150
if ( value instanceof Long longValue ) {

hibernate-core/src/main/java/org/hibernate/type/descriptor/java/OffsetDateTimeJavaType.java

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -137,24 +137,9 @@ public <X> X unwrap(OffsetDateTime offsetDateTime, Class<X> type, WrapperOptions
137137
}
138138

139139
if ( Timestamp.class.isAssignableFrom( type ) ) {
140-
/*
141-
* This works around two bugs:
142-
* - HHH-13266 (JDK-8061577): around and before 1900,
143-
* the number of milliseconds since the epoch does not mean the same thing
144-
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
145-
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
146-
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
147-
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
148-
* - around 1905, both methods are equally valid, so we don't really care which one is used.
149-
*/
150-
if ( offsetDateTime.getYear() < 1905 ) {
151-
return (X) Timestamp.valueOf(
152-
offsetDateTime.atZoneSameInstant( ZoneId.systemDefault() ).toLocalDateTime()
153-
);
154-
}
155-
else {
156-
return (X) Timestamp.from( offsetDateTime.toInstant() );
157-
}
140+
final ZonedDateTime dateTime =
141+
offsetDateTime.atZoneSameInstant( options.getJdbcZoneId() ); // convert to the JDBC timezone
142+
return (X) Timestamp.valueOf( dateTime.toLocalDateTime() );
158143
}
159144

160145
if ( java.sql.Date.class.isAssignableFrom( type ) ) {
@@ -195,22 +180,10 @@ public <X> OffsetDateTime wrap(X value, WrapperOptions options) {
195180
}
196181

197182
if (value instanceof Timestamp timestamp) {
198-
/*
199-
* This works around two bugs:
200-
* - HHH-13266 (JDK-8061577): around and before 1900,
201-
* the number of milliseconds since the epoch does not mean the same thing
202-
* for java.util and java.time, so conversion must be done using the year, month, day, hour, etc.
203-
* - HHH-13379 (JDK-4312621): after 1908 (approximately),
204-
* Daylight Saving Time introduces ambiguity in the year/month/day/hour/etc representation once a year
205-
* (on DST end), so conversion must be done using the number of milliseconds since the epoch.
206-
* - around 1905, both methods are equally valid, so we don't really care which one is used.
207-
*/
208-
if ( timestamp.getYear() < 5 ) { // Timestamp year 0 is 1900
209-
return timestamp.toLocalDateTime().atZone( ZoneId.systemDefault() ).toOffsetDateTime();
210-
}
211-
else {
212-
return OffsetDateTime.ofInstant( timestamp.toInstant(), ZoneId.systemDefault() );
213-
}
183+
return timestamp.toLocalDateTime()
184+
.atZone( options.getJdbcZoneId() ) // the Timestamp is in the JDBC timezone
185+
.withZoneSameInstant( ZoneId.systemDefault() ) // convert back to the VM timezone
186+
.toOffsetDateTime(); // return the corresponding OffsetDateTime
214187
}
215188

216189
if (value instanceof Date date) {

hibernate-core/src/main/java/org/hibernate/type/descriptor/java/OffsetTimeJavaType.java

Lines changed: 63 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import java.time.LocalTime;
1212
import java.time.OffsetDateTime;
1313
import java.time.OffsetTime;
14-
import java.time.ZoneOffset;
14+
import java.time.ZoneId;
1515
import java.time.format.DateTimeFormatter;
1616
import java.time.temporal.ChronoField;
1717
import java.util.Calendar;
@@ -92,59 +92,71 @@ public <X> X unwrap(OffsetTime offsetTime, Class<X> type, WrapperOptions options
9292
}
9393

9494
if ( LocalTime.class.isAssignableFrom( type ) ) {
95-
return (X) offsetTime.withOffsetSameInstant( getCurrentSystemOffset() ).toLocalTime();
95+
return (X) offsetTime.withOffsetSameInstant( options.getSystemZoneOffset() ).toLocalTime();
9696
}
9797

9898
if ( OffsetDateTime.class.isAssignableFrom( type ) ) {
99-
return (X) offsetTime.atDate( LocalDate.EPOCH );
99+
return (X) offsetDateTimeAtEpoch( offsetTime );
100100
}
101101

102102
// for legacy types, we assume that the JDBC timezone is passed to JDBC
103103
// (since PS.setTime() and friends do accept a timezone passed as a Calendar)
104104

105-
final OffsetTime jdbcOffsetTime = offsetTime.withOffsetSameInstant( getCurrentJdbcOffset(options) );
106105

107106
if ( Time.class.isAssignableFrom( type ) ) {
107+
// Use ZoneOffset rather than ZoneId in the conversion,
108+
// since the offset for a zone varies over time, but
109+
// a Time does not have an attached Date.
110+
final OffsetTime jdbcOffsetTime =
111+
offsetTime.withOffsetSameInstant( options.getJdbcZoneOffset() ); // convert to the JDBC timezone
108112
final Time time = Time.valueOf( jdbcOffsetTime.toLocalTime() );
109-
if ( jdbcOffsetTime.getNano() == 0 ) {
110-
return (X) time;
111-
}
112-
// Preserve milliseconds, which java.sql.Time supports
113-
return (X) new Time( time.getTime() + DateTimeUtils.roundToPrecision( jdbcOffsetTime.getNano(), 3 ) / 1000000 );
113+
// Time.valueOf() throws away milliseconds
114+
return (X) withMillis( jdbcOffsetTime, time );
114115
}
115116

116-
final OffsetDateTime jdbcOffsetDateTime = jdbcOffsetTime.atDate( LocalDate.EPOCH );
117-
118117
if ( Timestamp.class.isAssignableFrom( type ) ) {
119-
/*
120-
* Workaround for HHH-13266 (JDK-8061577).
121-
* Ideally we'd want to use Timestamp.from( jdbcOffsetDateTime.toInstant() ),
122-
* but this won't always work since Timestamp.from() assumes the number of
123-
* milliseconds since the epoch means the same thing in Timestamp and Instant,
124-
* but it doesn't, in particular before 1900.
125-
*/
126-
return (X) Timestamp.valueOf( jdbcOffsetDateTime.toLocalDateTime() );
118+
final OffsetTime jdbcOffsetTime =
119+
offsetTime.withOffsetSameInstant( options.getJdbcZoneOffset() ); // convert to the JDBC timezone
120+
return (X) Timestamp.valueOf( offsetDateTimeAtEpoch( jdbcOffsetTime ).toLocalDateTime() );
127121
}
128122

129123
if ( Calendar.class.isAssignableFrom( type ) ) {
130-
return (X) GregorianCalendar.from( jdbcOffsetDateTime.toZonedDateTime() );
124+
return (X) GregorianCalendar.from( offsetDateTimeAtEpoch( offsetTime ).toZonedDateTime() );
131125
}
132126

133127
// for instants, we assume that the JDBC timezone, if any, is ignored
134128

135-
final Instant instant = offsetTime.atDate( LocalDate.EPOCH ).toInstant();
136-
137129
if ( Long.class.isAssignableFrom( type ) ) {
138-
return (X) Long.valueOf( instant.toEpochMilli() );
130+
return (X) Long.valueOf( instantAtEpoch( offsetTime ).toEpochMilli() );
139131
}
140132

141133
if ( Date.class.isAssignableFrom( type ) ) {
142-
return (X) Date.from( instant );
134+
return (X) Date.from( instantAtEpoch( offsetTime ) );
143135
}
144136

145137
throw unknownUnwrap( type );
146138
}
147139

140+
private static Time withMillis(OffsetTime jdbcOffsetTime, Time time) {
141+
final int nanos = jdbcOffsetTime.getNano();
142+
if ( nanos == 0 ) {
143+
return time;
144+
}
145+
else {
146+
// Preserve milliseconds, which java.sql.Time supports
147+
final long millis = DateTimeUtils.roundToPrecision( nanos, 3 ) / 1_000_000;
148+
return new Time( time.getTime() + millis );
149+
}
150+
}
151+
152+
private static OffsetDateTime offsetDateTimeAtEpoch(OffsetTime jdbcOffsetTime) {
153+
return jdbcOffsetTime.atDate( LocalDate.EPOCH );
154+
}
155+
156+
private static Instant instantAtEpoch(OffsetTime offsetTime) {
157+
return offsetDateTimeAtEpoch( offsetTime ).toInstant();
158+
}
159+
148160
@Override
149161
public <X> OffsetTime wrap(X value, WrapperOptions options) {
150162
if ( value == null ) {
@@ -159,67 +171,39 @@ public <X> OffsetTime wrap(X value, WrapperOptions options) {
159171
}
160172

161173
if (value instanceof LocalTime localTime) {
162-
return localTime.atOffset( getCurrentSystemOffset() );
174+
return localTime.atOffset( options.getSystemZoneOffset() );
163175
}
164176

165177
if ( value instanceof OffsetDateTime offsetDateTime) {
166178
return offsetDateTime.toOffsetTime();
167179
}
168180

169-
/*
170-
* Also, in order to fix HHH-13357, and to be consistent with the conversion to Time (see above),
171-
* we set the offset to the current offset of the JVM (OffsetDateTime.now().getOffset()).
172-
* This is different from setting the *zone* to the current *zone* of the JVM (ZoneId.systemDefault()),
173-
* since a zone has a varying offset over time,
174-
* thus the zone might have a different offset for the given timezone than it has for the current date/time.
175-
* For example, if the timestamp represents 1970-01-01TXX:YY,
176-
* and the JVM is set to use Europe/Paris as a timezone, and the current time is 2019-04-16-08:53,
177-
* then applying the JVM timezone to the timestamp would result in the offset +01:00,
178-
* but applying the JVM offset would result in the offset +02:00, since DST is in effect at 2019-04-16-08:53.
179-
*
180-
* Of course none of this would be a problem if we just stored the offset in the database,
181-
* but I guess there are historical reasons that explain why we don't.
182-
*/
183-
184-
// for legacy types, we assume that the JDBC timezone is passed to JDBC
185-
// (since PS.setTime() and friends do accept a timezone passed as a Calendar)
186-
187181
if (value instanceof Time time) {
188-
final OffsetTime offsetTime = time.toLocalTime()
189-
.atOffset( getCurrentJdbcOffset( options) )
190-
.withOffsetSameInstant( getCurrentSystemOffset() );
191-
long millis = time.getTime() % 1000;
192-
if ( millis == 0 ) {
193-
return offsetTime;
194-
}
195-
if ( millis < 0 ) {
196-
// The milliseconds for a Time could be negative,
197-
// which usually means the time is in a different time zone
198-
millis += 1_000L;
199-
}
200-
return offsetTime.with( ChronoField.NANO_OF_SECOND, millis * 1_000_000L );
182+
// Use ZoneOffset rather than ZoneId in the conversion,
183+
// since the offset for a zone varies over time, but
184+
// a Time does not have an attached Date.
185+
final OffsetTime offsetTime =
186+
time.toLocalTime().atOffset( options.getJdbcZoneOffset() ) // the Timestamp is in the current JDBC timezone offset
187+
.withOffsetSameInstant( options.getSystemZoneOffset() ); // convert back to the VM timezone
188+
// Time.toLocalTime() strips off nanos
189+
return withNanos( time, offsetTime );
201190
}
202191

203192
if (value instanceof Timestamp timestamp) {
204-
/*
205-
* Workaround for HHH-13266 (JDK-8061577).
206-
* Ideally we'd want to use OffsetDateTime.ofInstant( ts.toInstant(), ... ),
207-
* but this won't always work since ts.toInstant() assumes the number of
208-
* milliseconds since the epoch means the same thing in Timestamp and Instant,
209-
* but it doesn't, in particular before 1900.
210-
*/
211-
return timestamp.toLocalDateTime().toLocalTime().atOffset( getCurrentJdbcOffset(options) )
212-
.withOffsetSameInstant( getCurrentSystemOffset() );
193+
return timestamp.toLocalDateTime()
194+
.atZone( options.getJdbcZoneId() ) // the Timestamp is in the JDBC timezone
195+
.withZoneSameInstant( ZoneId.systemDefault() ) // convert back to the VM timezone
196+
.toOffsetDateTime().toOffsetTime(); // return the time part
213197
}
214198

215199
if (value instanceof Date date) {
216-
return OffsetTime.ofInstant( date.toInstant(), getCurrentSystemOffset() );
200+
return OffsetTime.ofInstant( date.toInstant(), options.getSystemZoneOffset() );
217201
}
218202

219203
// for instants, we assume that the JDBC timezone, if any, is ignored
220204

221205
if (value instanceof Long millis) {
222-
return OffsetTime.ofInstant( Instant.ofEpochMilli(millis), getCurrentSystemOffset() );
206+
return OffsetTime.ofInstant( Instant.ofEpochMilli(millis), options.getSystemZoneOffset() );
223207
}
224208

225209
if (value instanceof Calendar calendar) {
@@ -229,17 +213,21 @@ public <X> OffsetTime wrap(X value, WrapperOptions options) {
229213
throw unknownWrap( value.getClass() );
230214
}
231215

232-
private static ZoneOffset getCurrentJdbcOffset(WrapperOptions options) {
233-
if ( options.getJdbcTimeZone() != null ) {
234-
return OffsetDateTime.now().atZoneSameInstant( options.getJdbcTimeZone().toZoneId() ).getOffset();
216+
private static OffsetTime withNanos(Time time, OffsetTime offsetTime) {
217+
final long millis = time.getTime() % 1000;
218+
final long nanos;
219+
if ( millis == 0 ) {
220+
return offsetTime;
221+
}
222+
else if ( millis < 0 ) {
223+
// The milliseconds for a Time could be negative,
224+
// which usually means the time is in a different time zone
225+
nanos = (millis + 1_000L) * 1_000_000L;
235226
}
236227
else {
237-
return getCurrentSystemOffset();
228+
nanos = millis * 1_000_000L;
238229
}
239-
}
240-
241-
private static ZoneOffset getCurrentSystemOffset() {
242-
return OffsetDateTime.now().getOffset();
230+
return offsetTime.with( ChronoField.NANO_OF_SECOND, nanos );
243231
}
244232

245233
@Override

0 commit comments

Comments
 (0)