diff --git a/core/src/main/java/google/registry/persistence/PersistenceModule.java b/core/src/main/java/google/registry/persistence/PersistenceModule.java
index acea14b8d..172521884 100644
--- a/core/src/main/java/google/registry/persistence/PersistenceModule.java
+++ b/core/src/main/java/google/registry/persistence/PersistenceModule.java
@@ -61,6 +61,8 @@ public class PersistenceModule {
// SessionFactory is created. Setting it to 'none' to turn off the feature.
properties.put(Environment.HBM2DDL_AUTO, "none");
+ // Hibernate converts any date to this timezone when writing to the database.
+ properties.put(Environment.JDBC_TIME_ZONE, "UTC");
properties.put(
Environment.PHYSICAL_NAMING_STRATEGY, NomulusNamingStrategy.class.getCanonicalName());
diff --git a/core/src/main/java/google/registry/persistence/ZonedDateTimeConverter.java b/core/src/main/java/google/registry/persistence/ZonedDateTimeConverter.java
new file mode 100644
index 000000000..cab9dbe08
--- /dev/null
+++ b/core/src/main/java/google/registry/persistence/ZonedDateTimeConverter.java
@@ -0,0 +1,48 @@
+// Copyright 2019 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.persistence;
+
+import java.sql.Timestamp;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import javax.annotation.Nullable;
+import javax.persistence.AttributeConverter;
+import javax.persistence.Converter;
+
+/**
+ * JPA converter to for storing/retrieving {@link ZonedDateTime} objects.
+ *
+ *
Hibernate provides a default converter for {@link ZonedDateTime}, but it converts timestamp to
+ * a non-normalized format, e.g., 2019-09-01T01:01:01Z will be converted to
+ * 2019-09-01T01:01:01Z[UTC]. This converter solves that problem by explicitly calling {@link
+ * ZoneId#normalized()} to normalize the zone id.
+ */
+@Converter(autoApply = true)
+public class ZonedDateTimeConverter implements AttributeConverter {
+
+ @Override
+ @Nullable
+ public Timestamp convertToDatabaseColumn(@Nullable ZonedDateTime attribute) {
+ return attribute == null ? null : Timestamp.from(attribute.toInstant());
+ }
+
+ @Override
+ @Nullable
+ public ZonedDateTime convertToEntityAttribute(@Nullable Timestamp dbData) {
+ return dbData == null
+ ? null
+ : ZonedDateTime.ofInstant(dbData.toInstant(), ZoneId.of("UTC").normalized());
+ }
+}
diff --git a/core/src/main/java/google/registry/tools/GenerateSqlSchemaCommand.java b/core/src/main/java/google/registry/tools/GenerateSqlSchemaCommand.java
index 22788c655..4f2e1bef2 100644
--- a/core/src/main/java/google/registry/tools/GenerateSqlSchemaCommand.java
+++ b/core/src/main/java/google/registry/tools/GenerateSqlSchemaCommand.java
@@ -31,6 +31,7 @@ import google.registry.persistence.CreateAutoTimestampConverter;
import google.registry.persistence.NomulusNamingStrategy;
import google.registry.persistence.NomulusPostgreSQLDialect;
import google.registry.persistence.UpdateAutoTimestampConverter;
+import google.registry.persistence.ZonedDateTimeConverter;
import google.registry.schema.domain.RegistryLock;
import google.registry.schema.tld.PremiumList;
import google.registry.schema.tmch.ClaimsList;
@@ -74,7 +75,8 @@ public class GenerateSqlSchemaCommand implements Command {
RegistryLock.class,
TransferData.class,
Trid.class,
- UpdateAutoTimestampConverter.class);
+ UpdateAutoTimestampConverter.class,
+ ZonedDateTimeConverter.class);
@VisibleForTesting
public static final String DB_OPTIONS_CLASH =
diff --git a/core/src/main/resources/META-INF/persistence.xml b/core/src/main/resources/META-INF/persistence.xml
index b68a9010e..705541169 100644
--- a/core/src/main/resources/META-INF/persistence.xml
+++ b/core/src/main/resources/META-INF/persistence.xml
@@ -35,6 +35,7 @@
google.registry.persistence.CreateAutoTimestampConverter
google.registry.persistence.UpdateAutoTimestampConverter
+ google.registry.persistence.ZonedDateTimeConverter
NONE
diff --git a/core/src/test/java/google/registry/persistence/ZonedDateTimeConverterTest.java b/core/src/test/java/google/registry/persistence/ZonedDateTimeConverterTest.java
new file mode 100644
index 000000000..a6697fc77
--- /dev/null
+++ b/core/src/test/java/google/registry/persistence/ZonedDateTimeConverterTest.java
@@ -0,0 +1,121 @@
+// Copyright 2019 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.persistence;
+
+import static com.google.common.truth.Truth.assertThat;
+import static google.registry.model.transaction.TransactionManagerFactory.jpaTm;
+
+import google.registry.model.ImmutableObject;
+import google.registry.model.transaction.JpaTransactionManagerRule;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import org.hibernate.cfg.Environment;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link ZonedDateTimeConverter}. */
+@RunWith(JUnit4.class)
+public class ZonedDateTimeConverterTest {
+
+ @Rule
+ public final JpaTransactionManagerRule jpaTmRule =
+ new JpaTransactionManagerRule.Builder()
+ .withEntityClass(TestEntity.class, ZonedDateTimeConverter.class)
+ .withProperty(Environment.HBM2DDL_AUTO, "update")
+ .build();
+
+ private final ZonedDateTimeConverter converter = new ZonedDateTimeConverter();
+
+ @Test
+ public void convertToDatabaseColumn_returnsNullIfInputIsNull() {
+ assertThat(converter.convertToDatabaseColumn(null)).isNull();
+ }
+
+ @Test
+ public void convertToDatabaseColumn_convertsCorrectly() {
+ ZonedDateTime zonedDateTime = ZonedDateTime.parse("2019-09-01T01:01:01Z");
+ assertThat(converter.convertToDatabaseColumn(zonedDateTime).toInstant())
+ .isEqualTo(zonedDateTime.toInstant());
+ }
+
+ @Test
+ public void convertToEntityAttribute_returnsNullIfInputIsNull() {
+ assertThat(converter.convertToEntityAttribute(null)).isNull();
+ }
+
+ @Test
+ public void convertToEntityAttribute_convertsCorrectly() {
+ ZonedDateTime zonedDateTime = ZonedDateTime.parse("2019-09-01T01:01:01Z");
+ Instant instant = zonedDateTime.toInstant();
+ assertThat(converter.convertToEntityAttribute(Timestamp.from(instant)))
+ .isEqualTo(zonedDateTime);
+ }
+
+ @Test
+ public void converter_generatesTimestampWithNormalizedZone() {
+ ZonedDateTime zdt = ZonedDateTime.parse("2019-09-01T01:01:01Z");
+ TestEntity entity = new TestEntity("normalized_utc_time", zdt);
+ jpaTm().transact(() -> jpaTm().getEntityManager().persist(entity));
+ TestEntity retrievedEntity =
+ jpaTm()
+ .transact(
+ () -> jpaTm().getEntityManager().find(TestEntity.class, "normalized_utc_time"));
+ assertThat(retrievedEntity.zdt.toString()).isEqualTo("2019-09-01T01:01:01Z");
+ }
+
+ @Test
+ public void converter_convertsNonNormalizedZoneCorrectly() {
+ ZonedDateTime zdt = ZonedDateTime.parse("2019-09-01T01:01:01Z[UTC]");
+ TestEntity entity = new TestEntity("non_normalized_utc_time", zdt);
+
+ jpaTm().transact(() -> jpaTm().getEntityManager().persist(entity));
+ TestEntity retrievedEntity =
+ jpaTm()
+ .transact(
+ () -> jpaTm().getEntityManager().find(TestEntity.class, "non_normalized_utc_time"));
+ assertThat(retrievedEntity.zdt.toString()).isEqualTo("2019-09-01T01:01:01Z");
+ }
+
+ @Test
+ public void converter_convertsNonUtcZoneCorrectly() {
+ ZonedDateTime zdt = ZonedDateTime.parse("2019-09-01T01:01:01+05:00");
+ TestEntity entity = new TestEntity("new_york_time", zdt);
+
+ jpaTm().transact(() -> jpaTm().getEntityManager().persist(entity));
+ TestEntity retrievedEntity =
+ jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "new_york_time"));
+ assertThat(retrievedEntity.zdt.toString()).isEqualTo("2019-08-31T20:01:01Z");
+ }
+
+ @Entity(name = "TestEntity") // Override entity name to avoid the nested class reference.
+ private static class TestEntity extends ImmutableObject {
+
+ @Id String name;
+
+ ZonedDateTime zdt;
+
+ public TestEntity() {}
+
+ public TestEntity(String name, ZonedDateTime zdt) {
+ this.name = name;
+ this.zdt = zdt;
+ }
+ }
+}
diff --git a/core/src/test/java/google/registry/rde/EscrowTaskRunnerTest.java b/core/src/test/java/google/registry/rde/EscrowTaskRunnerTest.java
index 33edcaf99..e6e6797ba 100644
--- a/core/src/test/java/google/registry/rde/EscrowTaskRunnerTest.java
+++ b/core/src/test/java/google/registry/rde/EscrowTaskRunnerTest.java
@@ -35,6 +35,7 @@ import google.registry.testing.FakeClock;
import google.registry.testing.FakeLockHandler;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
+import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@@ -54,9 +55,11 @@ public class EscrowTaskRunnerTest {
private final EscrowTask task = mock(EscrowTask.class);
private final FakeClock clock = new FakeClock(DateTime.parse("2000-01-01TZ"));
+ private DateTimeZone previousDateTimeZone;
private EscrowTaskRunner runner;
private Registry registry;
+
@Before
public void before() {
createTld("lol");
@@ -64,9 +67,15 @@ public class EscrowTaskRunnerTest {
runner = new EscrowTaskRunner();
runner.clock = clock;
runner.lockHandler = new FakeLockHandler(true);
+ previousDateTimeZone = DateTimeZone.getDefault();
DateTimeZone.setDefault(DateTimeZone.forID("America/New_York")); // Make sure UTC stuff works.
}
+ @After
+ public void after() {
+ DateTimeZone.setDefault(previousDateTimeZone);
+ }
+
@Test
public void testRun_cursorIsToday_advancesCursorToTomorrow() throws Exception {
clock.setTo(DateTime.parse("2006-06-06T00:30:00Z"));