Skip to content

Commit 51981f7

Browse files
committed
Introduce quarkus.hibernate-orm.mapping.timezone.default-storage
1 parent 50c7638 commit 51981f7

11 files changed

+413
-0
lines changed

extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import java.util.OptionalLong;
1111
import java.util.Set;
1212

13+
import org.hibernate.annotations.TimeZoneStorageType;
14+
1315
import io.quarkus.runtime.annotations.ConfigDocMapKey;
1416
import io.quarkus.runtime.annotations.ConfigDocSection;
1517
import io.quarkus.runtime.annotations.ConfigGroup;
@@ -154,6 +156,13 @@ public class HibernateOrmConfigPersistenceUnit {
154156
@ConvertWith(TrimmedStringConverter.class)
155157
public Optional<Set<String>> mappingFiles;
156158

159+
/**
160+
* Mapping configuration.
161+
*/
162+
@ConfigItem
163+
@ConfigDocSection
164+
public HibernateOrmConfigPersistenceUnitMapping mapping;
165+
157166
/**
158167
* Query related configuration.
159168
*/
@@ -265,6 +274,7 @@ public boolean isAnyPropertySet() {
265274
physicalNamingStrategy.isPresent() ||
266275
implicitNamingStrategy.isPresent() ||
267276
metadataBuilderContributor.isPresent() ||
277+
mapping.isAnyPropertySet() ||
268278
query.isAnyPropertySet() ||
269279
database.isAnyPropertySet() ||
270280
jdbc.isAnyPropertySet() ||
@@ -324,6 +334,58 @@ public boolean isAnyPropertySet() {
324334
}
325335
}
326336

337+
/**
338+
* Mapping-related configuration.
339+
*/
340+
@ConfigGroup
341+
public static class HibernateOrmConfigPersistenceUnitMapping {
342+
/**
343+
* How to store timezones in the database by default
344+
* for properties of type `OffsetDateTime` and `ZonedDateTime`.
345+
*
346+
* This default may be overridden on a per-property basis using `@TimeZoneStorage`.
347+
*
348+
* NOTE: Properties of type `OffsetTime` are https://hibernate.atlassian.net/browse/HHH-16287[not affected by this
349+
* setting].
350+
*
351+
* `default`::
352+
* Equivalent to `native` if supported, `normalize-utc` otherwise.
353+
* `auto`::
354+
* Equivalent to `native` if supported, `column` otherwise.
355+
* `native`::
356+
* Stores the timestamp and timezone in a column of type `timestamp with time zone`.
357+
* +
358+
* Only available on some databases/dialects;
359+
* if not supported, an exception will be thrown during static initialization.
360+
* `column`::
361+
* Stores the timezone in a separate column next to the timestamp column.
362+
* +
363+
* Use `@TimeZoneColumn` on the relevant entity property to customize the timezone column.
364+
* `normalize-utc`::
365+
* Does not store the timezone, and loses timezone information upon persisting.
366+
* +
367+
* Instead, normalizes the value to a timestamp in the UTC timezone.
368+
* `normalize`::
369+
* Does not store the timezone, and loses timezone information upon persisting.
370+
* +
371+
* Instead, normalizes the value:
372+
* * upon persisting to the database, to a timestamp in the JDBC timezone
373+
* set through `quarkus.hibernate-orm.jdbc.timezone`,
374+
* or the JVM default timezone if not set.
375+
* * upon reading back from the database, to the JVM default timezone.
376+
* +
377+
* Use this to get the legacy behavior of Quarkus 2 / Hibernate ORM 5 or older.
378+
*
379+
* @asciidoclet
380+
*/
381+
@ConfigItem(name = "timezone.default-storage", defaultValueDocumentation = "default")
382+
public Optional<TimeZoneStorageType> timeZoneDefaultStorage;
383+
384+
public boolean isAnyPropertySet() {
385+
return timeZoneDefaultStorage.isPresent();
386+
}
387+
}
388+
327389
@ConfigGroup
328390
public static class HibernateOrmConfigPersistenceUnitQuery {
329391

@@ -398,6 +460,8 @@ public static class HibernateOrmConfigPersistenceUnitJdbc {
398460

399461
/**
400462
* The time zone pushed to the JDBC driver.
463+
*
464+
* See `quarkus.hibernate-orm.mapping.timezone.default-storage`.
401465
*/
402466
@ConfigItem
403467
@ConvertWith(TrimmedStringConverter.class)

extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,12 @@ private static void producePersistenceUnitDescriptorFromConfig(
10171017
className -> descriptor.getProperties()
10181018
.setProperty(EntityManagerFactoryBuilderImpl.METADATA_BUILDER_CONTRIBUTOR, className));
10191019

1020+
// Mapping
1021+
if (persistenceUnitConfig.mapping.timeZoneDefaultStorage.isPresent()) {
1022+
descriptor.getProperties().setProperty(AvailableSettings.TIMEZONE_DEFAULT_STORAGE,
1023+
persistenceUnitConfig.mapping.timeZoneDefaultStorage.get().name());
1024+
}
1025+
10201026
//charset
10211027
descriptor.getProperties().setProperty(AvailableSettings.HBM2DDL_CHARSET_NAME,
10221028
persistenceUnitConfig.database.charset.name());

extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/SchemaUtil.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import jakarta.persistence.EntityManagerFactory;
88

99
import org.hibernate.engine.spi.SessionFactoryImplementor;
10+
import org.hibernate.metamodel.MappingMetamodel;
11+
import org.hibernate.metamodel.mapping.SelectableConsumer;
12+
import org.hibernate.metamodel.mapping.SelectableMapping;
1013
import org.hibernate.persister.entity.AbstractEntityPersister;
14+
import org.hibernate.persister.entity.EntityPersister;
1115

1216
public final class SchemaUtil {
1317

@@ -27,4 +31,23 @@ public static Set<String> getColumnNames(EntityManagerFactory entityManagerFacto
2731
}
2832
return result;
2933
}
34+
35+
public static String getColumnTypeName(EntityManagerFactory entityManagerFactory, Class<?> entityType,
36+
String columnName) {
37+
MappingMetamodel domainModel = entityManagerFactory
38+
.unwrap(SessionFactoryImplementor.class).getRuntimeMetamodels().getMappingMetamodel();
39+
EntityPersister entityDescriptor = domainModel.findEntityDescriptor(entityType);
40+
var columnFinder = new SelectableConsumer() {
41+
private SelectableMapping found;
42+
43+
@Override
44+
public void accept(int selectionIndex, SelectableMapping selectableMapping) {
45+
if (found == null && selectableMapping.getSelectableName().equals(columnName)) {
46+
found = selectableMapping;
47+
}
48+
}
49+
};
50+
entityDescriptor.forEachSelectable(columnFinder);
51+
return columnFinder.found.getJdbcMapping().getJdbcType().getFriendlyName();
52+
}
3053
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.quarkus.hibernate.orm.mapping;
2+
3+
import java.time.LocalDateTime;
4+
import java.time.Month;
5+
import java.time.OffsetDateTime;
6+
import java.time.OffsetTime;
7+
import java.time.ZoneId;
8+
import java.time.ZoneOffset;
9+
import java.time.ZonedDateTime;
10+
11+
import jakarta.inject.Inject;
12+
13+
import org.assertj.core.api.SoftAssertions;
14+
import org.hibernate.Session;
15+
import org.hibernate.SessionFactory;
16+
17+
import io.quarkus.narayana.jta.QuarkusTransaction;
18+
19+
public class AbstractTimezoneDefaultStorageTest {
20+
21+
private static final LocalDateTime LOCAL_DATE_TIME_TO_TEST = LocalDateTime.of(2017, Month.NOVEMBER, 6, 19, 19, 0);
22+
public static final ZonedDateTime PERSISTED_ZONED_DATE_TIME = LOCAL_DATE_TIME_TO_TEST.atZone(ZoneId.of("Africa/Cairo"));
23+
public static final OffsetDateTime PERSISTED_OFFSET_DATE_TIME = LOCAL_DATE_TIME_TO_TEST.atOffset(ZoneOffset.ofHours(3));
24+
public static final OffsetTime PERSISTED_OFFSET_TIME = LOCAL_DATE_TIME_TO_TEST.toLocalTime()
25+
.atOffset(ZoneOffset.ofHours(3));
26+
27+
@Inject
28+
SessionFactory sessionFactory;
29+
30+
@Inject
31+
Session session;
32+
33+
protected long persistWithValuesToTest() {
34+
return QuarkusTransaction.requiringNew().call(() -> {
35+
var entity = new EntityWithTimezones(PERSISTED_ZONED_DATE_TIME, PERSISTED_OFFSET_DATE_TIME);
36+
session.persist(entity);
37+
return entity.id;
38+
});
39+
}
40+
41+
protected void assertLoadedValues(long id, ZonedDateTime expectedZonedDateTime, OffsetDateTime expectedOffsetDateTime) {
42+
QuarkusTransaction.requiringNew().run(() -> {
43+
var entity = session.find(EntityWithTimezones.class, id);
44+
SoftAssertions.assertSoftly(assertions -> {
45+
assertions.assertThat(entity).extracting("zonedDateTime").isEqualTo(expectedZonedDateTime);
46+
assertions.assertThat(entity).extracting("offsetDateTime").isEqualTo(expectedOffsetDateTime);
47+
});
48+
});
49+
}
50+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.quarkus.hibernate.orm.mapping;
2+
3+
import java.time.OffsetDateTime;
4+
import java.time.ZonedDateTime;
5+
6+
import jakarta.persistence.Entity;
7+
import jakarta.persistence.GeneratedValue;
8+
import jakarta.persistence.Id;
9+
10+
@Entity
11+
public class EntityWithTimezones {
12+
13+
@Id
14+
@GeneratedValue
15+
Long id;
16+
17+
public EntityWithTimezones() {
18+
}
19+
20+
public EntityWithTimezones(ZonedDateTime zonedDateTime, OffsetDateTime offsetDateTime) {
21+
this.zonedDateTime = zonedDateTime;
22+
this.offsetDateTime = offsetDateTime;
23+
}
24+
25+
public ZonedDateTime zonedDateTime;
26+
27+
public OffsetDateTime offsetDateTime;
28+
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.quarkus.hibernate.orm.mapping;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.RegisterExtension;
7+
8+
import io.quarkus.hibernate.orm.SchemaUtil;
9+
import io.quarkus.hibernate.orm.SmokeTestUtils;
10+
import io.quarkus.test.QuarkusUnitTest;
11+
12+
public class TimezoneDefaultStorageAutoTest extends AbstractTimezoneDefaultStorageTest {
13+
14+
@RegisterExtension
15+
static QuarkusUnitTest TEST = new QuarkusUnitTest()
16+
.withApplicationRoot((jar) -> jar
17+
.addClasses(EntityWithTimezones.class)
18+
.addClasses(SchemaUtil.class, SmokeTestUtils.class))
19+
.withConfigurationResource("application.properties")
20+
.overrideConfigKey("quarkus.hibernate-orm.mapping.timezone.default-storage", "auto");
21+
22+
@Test
23+
public void schema() throws Exception {
24+
assertThat(SchemaUtil.getColumnNames(sessionFactory, EntityWithTimezones.class))
25+
.doesNotContain("zonedDateTime_tz", "offsetDateTime_tz");
26+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "zonedDateTime"))
27+
.isEqualTo("TIMESTAMP_WITH_TIMEZONE");
28+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "offsetDateTime"))
29+
.isEqualTo("TIMESTAMP_WITH_TIMEZONE");
30+
}
31+
32+
@Test
33+
public void persistAndLoad() {
34+
long id = persistWithValuesToTest();
35+
// For some reason native storage (with H2 at least) preserves the offset, but not the zone ID.
36+
assertLoadedValues(id, PERSISTED_ZONED_DATE_TIME.withZoneSameInstant(PERSISTED_ZONED_DATE_TIME.getOffset()),
37+
PERSISTED_OFFSET_DATE_TIME);
38+
}
39+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package io.quarkus.hibernate.orm.mapping;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.RegisterExtension;
7+
8+
import io.quarkus.hibernate.orm.SchemaUtil;
9+
import io.quarkus.hibernate.orm.SmokeTestUtils;
10+
import io.quarkus.test.QuarkusUnitTest;
11+
12+
public class TimezoneDefaultStorageColumnTest extends AbstractTimezoneDefaultStorageTest {
13+
14+
@RegisterExtension
15+
static QuarkusUnitTest TEST = new QuarkusUnitTest()
16+
.withApplicationRoot((jar) -> jar
17+
.addClasses(EntityWithTimezones.class)
18+
.addClasses(SchemaUtil.class, SmokeTestUtils.class))
19+
.withConfigurationResource("application.properties")
20+
.overrideConfigKey("quarkus.hibernate-orm.mapping.timezone.default-storage", "column");
21+
22+
@Test
23+
public void schema() throws Exception {
24+
assertThat(SchemaUtil.getColumnNames(sessionFactory, EntityWithTimezones.class))
25+
.contains("zonedDateTime_tz", "offsetDateTime_tz")
26+
// For some reason we don't get a TZ column for OffsetTime
27+
.doesNotContain("offsetTime_tz");
28+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "zonedDateTime"))
29+
.isEqualTo("TIMESTAMP_UTC");
30+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "offsetDateTime"))
31+
.isEqualTo("TIMESTAMP_UTC");
32+
}
33+
34+
@Test
35+
public void persistAndLoad() {
36+
long id = persistWithValuesToTest();
37+
// For some reason column storage preserves the offset, but not the zone ID.
38+
assertLoadedValues(id, PERSISTED_ZONED_DATE_TIME.withZoneSameInstant(PERSISTED_ZONED_DATE_TIME.getOffset()),
39+
PERSISTED_OFFSET_DATE_TIME);
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.quarkus.hibernate.orm.mapping;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.RegisterExtension;
7+
8+
import io.quarkus.hibernate.orm.SchemaUtil;
9+
import io.quarkus.hibernate.orm.SmokeTestUtils;
10+
import io.quarkus.test.QuarkusUnitTest;
11+
12+
public class TimezoneDefaultStorageDefaultTest extends AbstractTimezoneDefaultStorageTest {
13+
14+
@RegisterExtension
15+
static QuarkusUnitTest TEST = new QuarkusUnitTest()
16+
.withApplicationRoot((jar) -> jar
17+
.addClasses(EntityWithTimezones.class)
18+
.addClasses(SchemaUtil.class, SmokeTestUtils.class))
19+
.withConfigurationResource("application.properties");
20+
21+
@Test
22+
public void schema() throws Exception {
23+
assertThat(SchemaUtil.getColumnNames(sessionFactory, EntityWithTimezones.class))
24+
.doesNotContain("zonedDateTime_tz", "offsetDateTime_tz");
25+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "zonedDateTime"))
26+
.isEqualTo("TIMESTAMP_WITH_TIMEZONE");
27+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "offsetDateTime"))
28+
.isEqualTo("TIMESTAMP_WITH_TIMEZONE");
29+
}
30+
31+
@Test
32+
public void persistAndLoad() {
33+
long id = persistWithValuesToTest();
34+
// For some reason native storage (with H2 at least) preserves the offset, but not the zone ID.
35+
assertLoadedValues(id, PERSISTED_ZONED_DATE_TIME.withZoneSameInstant(PERSISTED_ZONED_DATE_TIME.getOffset()),
36+
PERSISTED_OFFSET_DATE_TIME);
37+
}
38+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.quarkus.hibernate.orm.mapping;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.RegisterExtension;
7+
8+
import io.quarkus.hibernate.orm.SchemaUtil;
9+
import io.quarkus.hibernate.orm.SmokeTestUtils;
10+
import io.quarkus.test.QuarkusUnitTest;
11+
12+
public class TimezoneDefaultStorageNativeTest extends AbstractTimezoneDefaultStorageTest {
13+
14+
@RegisterExtension
15+
static QuarkusUnitTest TEST = new QuarkusUnitTest()
16+
.withApplicationRoot((jar) -> jar
17+
.addClasses(EntityWithTimezones.class)
18+
.addClasses(SchemaUtil.class, SmokeTestUtils.class))
19+
.withConfigurationResource("application.properties")
20+
.overrideConfigKey("quarkus.hibernate-orm.mapping.timezone.default-storage", "native");
21+
22+
@Test
23+
public void schema() throws Exception {
24+
assertThat(SchemaUtil.getColumnNames(sessionFactory, EntityWithTimezones.class))
25+
.doesNotContain("zonedDateTime_tz", "offsetDateTime_tz");
26+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "zonedDateTime"))
27+
.isEqualTo("TIMESTAMP_WITH_TIMEZONE");
28+
assertThat(SchemaUtil.getColumnTypeName(sessionFactory, EntityWithTimezones.class, "offsetDateTime"))
29+
.isEqualTo("TIMESTAMP_WITH_TIMEZONE");
30+
}
31+
32+
@Test
33+
public void persistAndLoad() {
34+
long id = persistWithValuesToTest();
35+
// For some reason native storage (with H2 at least) preserves the offset, but not the zone ID.
36+
assertLoadedValues(id, PERSISTED_ZONED_DATE_TIME.withZoneSameInstant(PERSISTED_ZONED_DATE_TIME.getOffset()),
37+
PERSISTED_OFFSET_DATE_TIME);
38+
}
39+
}

0 commit comments

Comments
 (0)