diff --git a/hibernate-core/src/test/java/org/hibernate/test/type/ZonedDateTimeTest.java b/hibernate-core/src/test/java/org/hibernate/test/type/ZonedDateTimeTest.java index 978b21d04593..55680baaeed1 100644 --- a/hibernate-core/src/test/java/org/hibernate/test/type/ZonedDateTimeTest.java +++ b/hibernate-core/src/test/java/org/hibernate/test/type/ZonedDateTimeTest.java @@ -6,157 +6,305 @@ */ package org.hibernate.test.type; -import java.time.ZoneOffset; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDateTime; import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Arrays; import java.util.List; +import java.util.TimeZone; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Consumer; +import javax.persistence.Basic; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Table; import org.hibernate.Query; -import org.hibernate.Session; -import org.hibernate.resource.transaction.spi.TransactionStatus; +import org.hibernate.dialect.Dialect; +import org.hibernate.dialect.PostgreSQL81Dialect; import org.hibernate.type.ZonedDateTimeType; +import org.hibernate.testing.TestForIssue; import org.hibernate.testing.junit4.BaseNonConfigCoreFunctionalTestCase; +import org.hibernate.testing.junit4.CustomParameterized; +import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThat; -import static org.junit.Assert.fail; +import static org.junit.Assert.assertTrue; /** * @author Andrea Boriero */ +@RunWith(CustomParameterized.class) +@TestForIssue(jiraKey = "HHH-10372") public class ZonedDateTimeTest extends BaseNonConfigCoreFunctionalTestCase { - @Override - protected Class[] getAnnotatedClasses() { - return new Class[] {ZonedDateTimeEvent.class}; + + private static Dialect DIALECT; + private static Dialect determineDialect() { + try { + return Dialect.getDialect(); + } + catch (Exception e) { + return new Dialect() { + }; + } } - @Test - public void testZoneDateTimeWithHoursZoneOffset() { - final ZonedDateTime expectedStartDate = ZonedDateTime.of( - 2015, - 1, - 1, - 0, - 0, - 0, - 0, - ZoneOffset.ofHours( 5 ) + /* + * The default timezone affects conversions done using java.util, + * which is why we take it into account even when testing ZonedDateTime. + */ + @Parameterized.Parameters(name = "{0}-{1}-{2}T{3}:{4}:{5}.{6}[{7}] [JVM TZ: {8}]") + public static List data() { + DIALECT = determineDialect(); + return Arrays.asList( + // Not affected by HHH-13266 + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+10:00", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+07:00", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+01:30", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+01:00", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "Europe/Paris", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "Europe/London", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+00:30", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT-02:00", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT-06:00", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT-08:00", ZoneId.of( "UTC-8" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+10:00", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+07:00", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+01:30", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+01:00", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 500, "GMT+01:00", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "Europe/Paris", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "Europe/London", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT+00:30", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT-02:00", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT-06:00", ZoneId.of( "Europe/Paris" ) ), + data( 2017, 11, 6, 19, 19, 1, 0, "GMT-08:00", ZoneId.of( "Europe/Paris" ) ), + data( 1970, 1, 1, 0, 0, 0, 0, "GMT+01:00", ZoneId.of( "GMT" ) ), + data( 1970, 1, 1, 0, 0, 0, 0, "GMT+00:00", ZoneId.of( "GMT" ) ), + data( 1970, 1, 1, 0, 0, 0, 0, "GMT-01:00", ZoneId.of( "GMT" ) ), + data( 1900, 1, 1, 0, 0, 0, 0, "GMT+01:00", ZoneId.of( "GMT" ) ), + data( 1900, 1, 1, 0, 0, 0, 0, "GMT+00:00", ZoneId.of( "GMT" ) ), + data( 1900, 1, 1, 0, 0, 0, 0, "GMT-01:00", ZoneId.of( "GMT" ) ), + data( 1900, 1, 1, 0, 0, 0, 0, "GMT+00:00", ZoneId.of( "Europe/Oslo" ) ), + data( 1900, 1, 1, 0, 9, 21, 0, "GMT+00:09:21", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 9, 21, 0, "Europe/Paris", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 19, 31, 0, "Europe/Paris", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 19, 32, 0, "GMT+00:19:32", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 19, 32, 0, "Europe/Amsterdam", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 19, 32, 0, "GMT+00:19:32", ZoneId.of( "Europe/Amsterdam" ) ), + data( 1900, 1, 1, 0, 19, 32, 0, "Europe/Amsterdam", ZoneId.of( "Europe/Amsterdam" ) ), + // Affected by HHH-13266 + data( 1892, 1, 1, 0, 0, 0, 0, "GMT+00:00", ZoneId.of( "Europe/Oslo" ) ), + data( 1892, 1, 1, 0, 0, 0, 0, "Europe/Oslo", ZoneId.of( "Europe/Oslo" ) ), + data( 1900, 1, 1, 0, 9, 20, 0, "GMT+00:09:21", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 9, 20, 0, "Europe/Paris", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 19, 31, 0, "GMT+00:19:32", ZoneId.of( "Europe/Paris" ) ), + data( 1900, 1, 1, 0, 19, 31, 0, "GMT+00:19:32", ZoneId.of( "Europe/Amsterdam" ) ), + data( 1900, 1, 1, 0, 19, 31, 0, "Europe/Amsterdam", ZoneId.of( "Europe/Amsterdam" ) ), + data( 1600, 1, 1, 0, 0, 0, 0, "GMT+00:19:32", ZoneId.of( "Europe/Amsterdam" ) ), + data( 1600, 1, 1, 0, 0, 0, 0, "Europe/Amsterdam", ZoneId.of( "Europe/Amsterdam" ) ) ); + } - saveZoneDateTimeEventWithStartDate( expectedStartDate ); - - checkSavedZonedDateTimeIsEqual( expectedStartDate ); - compareSavedZonedDateTimeWith( expectedStartDate ); + private static Object[] data(int year, int month, int day, + int hour, int minute, int second, int nanosecond, String zone, ZoneId defaultTimeZone) { + if ( DIALECT instanceof PostgreSQL81Dialect ) { + // PostgreSQL apparently doesn't support nanosecond precision correctly + nanosecond = 0; + } + return new Object[] { year, month, day, hour, minute, second, nanosecond, zone, defaultTimeZone }; } - @Test - public void testZoneDateTimeWithUTCZoneOffset() { - final ZonedDateTime expectedStartDate = ZonedDateTime.of( - 1, - 1, - 1, - 0, - 0, - 0, - 0, - ZoneOffset.UTC - ); + private final int year; + private final int month; + private final int day; + private final int hour; + private final int minute; + private final int second; + private final int nanosecond; + private final String zone; + private final ZoneId defaultTimeZone; + + public ZonedDateTimeTest(int year, int month, int day, + int hour, int minute, int second, int nanosecond, String zone, ZoneId defaultTimeZone) { + this.year = year; + this.month = month; + this.day = day; + this.hour = hour; + this.minute = minute; + this.second = second; + this.nanosecond = nanosecond; + this.zone = zone; + this.defaultTimeZone = defaultTimeZone; + } - saveZoneDateTimeEventWithStartDate( expectedStartDate ); + private ZonedDateTime getOriginalZonedDateTime() { + return ZonedDateTime.of( year, month, day, hour, minute, second, nanosecond, ZoneId.of( zone ) ); + } - checkSavedZonedDateTimeIsEqual( expectedStartDate ); - compareSavedZonedDateTimeWith( expectedStartDate ); + private ZonedDateTime getExpectedZonedDateTime() { + return getOriginalZonedDateTime().withZoneSameInstant( ZoneId.systemDefault() ); } - @Test - public void testRetrievingEntityByZoneDateTime() { - - final ZonedDateTime startDate = ZonedDateTime.of( - 1, - 1, - 1, - 0, - 0, - 0, - 0, - ZoneOffset.ofHours( 3 ) + private Timestamp getExpectedTimestamp() { + LocalDateTime dateTimeInDefaultTimeZone = getOriginalZonedDateTime().withZoneSameInstant( ZoneId.systemDefault() ) + .toLocalDateTime(); + return new Timestamp( + dateTimeInDefaultTimeZone.getYear() - 1900, dateTimeInDefaultTimeZone.getMonthValue() - 1, + dateTimeInDefaultTimeZone.getDayOfMonth(), + dateTimeInDefaultTimeZone.getHour(), dateTimeInDefaultTimeZone.getMinute(), + dateTimeInDefaultTimeZone.getSecond(), + dateTimeInDefaultTimeZone.getNano() ); + } - saveZoneDateTimeEventWithStartDate( startDate ); + @Override + protected Class[] getAnnotatedClasses() { + return new Class[] { EntityWithZonedDateTime.class }; + } - final Session s = openSession(); - try { - Query query = s.createQuery( "from ZonedDateTimeEvent o where o.startDate = :date" ); - query.setParameter( "date", startDate, ZonedDateTimeType.INSTANCE ); - List list = query.list(); - assertThat( list.size(), is( 1 ) ); - } - finally { - s.close(); - } + @Before + public void cleanup() { + inTransaction( session -> { + session.createNativeQuery( "DELETE FROM theentity" ).executeUpdate(); + } ); } - private void checkSavedZonedDateTimeIsEqual(ZonedDateTime startdate) { - final Session s = openSession(); - try { - final ZonedDateTimeEvent zonedDateTimeEvent = s.get( ZonedDateTimeEvent.class, 1L ); - assertThat( zonedDateTimeEvent.startDate.isEqual( startdate ), is( true ) ); - } - finally { - s.close(); - } + @Test + @TestForIssue(jiraKey = "HHH-13266") + public void writeThenRead() { + withDefaultTimeZone( defaultTimeZone, () -> { + inTransaction( session -> { + session.persist( new EntityWithZonedDateTime( 1, getOriginalZonedDateTime() ) ); + } ); + inTransaction( session -> { + ZonedDateTime read = session.find( ZonedDateTimeTest.EntityWithZonedDateTime.class, 1 ).value; + assertEquals( + "Writing then reading a value should return the original value", + getExpectedZonedDateTime(), read + ); + assertTrue( + getExpectedZonedDateTime().isEqual( read ) + ); + assertEquals( + 0, + ZonedDateTimeType.INSTANCE.getComparator().compare( getExpectedZonedDateTime(), read ) + ); + } ); + } ); } - private void compareSavedZonedDateTimeWith(ZonedDateTime startdate) { - final Session s = openSession(); - try { - final ZonedDateTimeEvent zonedDateTimeEvent = s.get( ZonedDateTimeEvent.class, 1L ); - assertThat( - ZonedDateTimeType.INSTANCE.getComparator().compare( zonedDateTimeEvent.startDate, startdate ), - is( 0 ) - ); - } - finally { - s.close(); - } + @Test + @TestForIssue(jiraKey = "HHH-13266") + public void writeThenNativeRead() { + withDefaultTimeZone( defaultTimeZone, () -> { + inTransaction( session -> { + session.persist( new EntityWithZonedDateTime( 1, getOriginalZonedDateTime() ) ); + } ); + inTransaction( session -> { + session.doWork( connection -> { + final PreparedStatement statement = connection.prepareStatement( + "SELECT thevalue FROM theentity WHERE theid = ?" + ); + statement.setInt( 1, 1 ); + statement.execute(); + final ResultSet resultSet = statement.getResultSet(); + resultSet.next(); + Timestamp nativeRead = resultSet.getTimestamp( 1 ); + assertEquals( + "Raw values written in database should match the original value (same instant)", + getExpectedTimestamp(), + nativeRead + ); + } ); + } ); + } ); } - private void saveZoneDateTimeEventWithStartDate(ZonedDateTime startdate) { - final ZonedDateTimeEvent event = new ZonedDateTimeEvent(); - event.id = 1L; - event.startDate = startdate; + @Test + public void testRetrievingEntityByZonedDateTime() { + withDefaultTimeZone( defaultTimeZone, () -> { + inTransaction( session -> { + session.persist( new EntityWithZonedDateTime( 1, getOriginalZonedDateTime() ) ); + } ); + Consumer checkOneMatch = expected -> inSession( s -> { + Query query = s.createQuery( "from EntityWithZonedDateTime o where o.value = :date" ); + query.setParameter( "date", expected, ZonedDateTimeType.INSTANCE ); + List list = query.list(); + assertThat( list.size(), is( 1 ) ); + } ); + checkOneMatch.accept( getOriginalZonedDateTime() ); + checkOneMatch.accept( getExpectedZonedDateTime() ); + checkOneMatch.accept( getExpectedZonedDateTime().withZoneSameInstant( ZoneOffset.UTC ) ); + } ); + } - final Session s = openSession(); - s.getTransaction().begin(); + private static void withDefaultTimeZone(ZoneId zoneId, Runnable runnable) { + TimeZone timeZoneBefore = TimeZone.getDefault(); + TimeZone.setDefault( TimeZone.getTimeZone( zoneId ) ); + /* + * Run the code in a new thread, because some libraries (looking at you, h2 JDBC driver) + * cache data dependent on the default timezone in thread local variables, + * and we want this data to be reinitialized with the new default time zone. + */ try { - s.save( event ); - s.getTransaction().commit(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future future = executor.submit( runnable ); + executor.shutdown(); + future.get(); } - catch (Exception e) { - if ( s.getTransaction() != null && s.getTransaction().getStatus() == TransactionStatus.ACTIVE ) { - s.getTransaction().rollback(); + catch (InterruptedException e) { + throw new IllegalStateException( "Interrupted while testing", e ); + } + catch (ExecutionException e) { + Throwable cause = e.getCause(); + if ( cause instanceof RuntimeException ) { + throw (RuntimeException) cause; + } + else if ( cause instanceof Error ) { + throw (Error) cause; + } + else { + throw new IllegalStateException( "Unexpected exception while testing", cause ); } - fail( e.getMessage() ); } finally { - s.close(); + TimeZone.setDefault( timeZoneBefore ); } } - @Entity(name = "ZonedDateTimeEvent") - @Table(name = "ZONE_DATE_TIME_EVENT") - public static class ZonedDateTimeEvent { - + @Entity(name = "EntityWithZonedDateTime") + @Table(name = "theentity") + private static final class EntityWithZonedDateTime { @Id - private Long id; + @Column(name = "theid") + private Integer id; + + @Basic + @Column(name = "thevalue") + private ZonedDateTime value; - @Column(name = "START_DATE") - private ZonedDateTime startDate; + protected EntityWithZonedDateTime() { + } + + private EntityWithZonedDateTime(int id, ZonedDateTime value) { + this.id = id; + this.value = value; + } } @Override