Сегодня любой Java разработчик сходу сможет правильно ответить на вопрос «Как смапить дату и время из колонки таблицы БД на поле в Java классе?». Или нет?

На самом деле, нюансов по ходу решения этой задачи может возникнуть немало.

В новом переводе от команды Spring АйО рассказывается про подробности работы с современным API java.time, правильный маппинг данных с учётом часовых поясов, устаревших типов java.util.Date, Calendar и многое другое.


Базы данных поддерживают различные типы данных для хранения информации о дате и времени. Наиболее используемые из них:

  • DATE — для сохранения даты без времени,

  • TIME — для хранения времени без даты,

  • TIMESTAMP — для хранения информации о дате и времени.

Вы можете маппить все эти типы с помощью JPA и Hibernate.

Однако вам нужно понять, к какому типу Java вы хотите привязать столбец базы данных. Язык Java предоставляет множество классов для представления информации о дате и времени, например:

  • Типы из API даты и времени (Date and Time API):
    Это такие классы, как java.time.LocalDate, java.time.LocalDateTime, java.time.OffsetTime, java.time.OffsetDateTime, java.time.ZonedDateTime, java.time.Duration. Современные приложения должны использовать именно их вместо устаревших типов.

  • SQL-специфичные типы: java.sql.Date, java.sql.Time и java.sql.Timestamp.

  • А также устаревшие типы: java.util.Date и java.util.Calendar.

Современные версии стандарта Jakarta Persistence поддерживают большинство из перечисленных типов. Кроме того, Hibernate предоставляет проприетарную поддержку практически для всех остальных.

В этой статье я продемонстрирую различные варианты маппинга:

  1. Мы начнем с маппинга классов из пакета java.time, так как они наиболее актуальны для современных приложений.

  2. Затем я покажу, как маппить классы из пакета java.util, которые до сих пор используются во многих приложениях.

  3. И завершим статью разбором маппинга классов из пакета java.sql.

Содержание

  • Маппинг классов java.time

  • Работа с ZonedDateTime

  • Поддержка ZonedDateTime в Hibernate 6

  • Настройка обработки часовых поясов

  • Типы хранения часовых поясов в Hibernate (TimeZoneStorageTypes)

  • Поддержка ZonedDateTime в Hibernate 5

  • Маппинг классов java.util

  • Маппинг классов java.sql

  • Заключение

Маппинг классов java.time

Классы API даты и времени являются наиболее популярным способом представления значений даты и времени. Они разделяют информацию о дате и времени и устраняют недостатки устаревшего класса java.util.Date.

Начиная с Hibernate 5 и JPA 2.2, вы можете использовать следующие классы в качестве типов атрибутов.

Java Type

JPA

Hibernate

JDBC Type

java.time.LocalDate

v2.2

v5

DATE

java.time.LocalTime

v2.2

v5

TIME

java.time.LocalDateTime

v2.2

v5

TIMESTAMP

java.time.OffsetTime

v2.2

v5

TIME_WITH_TIMEZONE

java.time.OffsetDateTime

v2.2

v5

TIMESTAMP_WITH_TIMEZONE

java.time.Instant

v3.2

v5

TIMESTAMP

java.time.ZonedDateTime

v5

v6

TIMESTAMP

TIMESTAMP_WITH_TIMEZONE

java.time.Year

v3.2

v6

INTEGER

java.time.Timezone

v6

VARCHAR

java.time.ZoneOffset

v6

VARCHAR

java.time.Duration

v6

BIGINT

Как видно из таблицы, поддержка API даты и времени постепенно расширялась, и Hibernate поддерживает несколько больше классов, чем JPA. Вы можете легко добавить поддержку дополнительных классов, реализовав AttributeConverter. В одной из предыдущих статей я использовал этот подход для маппинга атрибута типа Duration с помощью JPA.

В зависимости от версии Hibernate следует быть осторожным при использовании ZonedDateTime. В Hibernate 5 работа с часовыми поясами и маппинг в столбец типа TIMESTAMP имеет несколько подводных камней. Hibernate 6 предоставляет больше контроля над этим процессом, но вам все равно нужно понимать, как это работает. Подробнее об этом я рассказываю в разделе Работа с ZonedDateTime.

Сначала давайте рассмотрим базовый маппинг сущности и простой тестовый пример с использованием этой сущности.

@Entity

public class MyEntity {

	private LocalDate localDate;

	private LocalDateTime localDateTime;

	private OffsetTime offsetTime;

	private OffsetDateTime offsetDateTime;

	// Hibernate-specific - not supported by JPA

	private Duration duration;

	private Instant instant;

	private ZonedDateTime zonedDateTime;

	...

}
MyEntity e = new MyEntity();

e.setLocalDate(LocalDate.of(2019, 7, 19));
e.setLocalDateTime(LocalDateTime.of(2019, 7, 19, 15, 05, 30));
e.setOffsetTime(OffsetTime.of(15, 05, 30, 0, ZoneOffset.ofHours(+2)));
e.setOffsetDateTime(OffsetDateTime.of(2019, 7, 19, 15, 05, 30, 0, ZoneOffset.ofHours(+2)));

// Hibernate-specific - not supported by JPA
e.setDuration(Duration.ofHours(2));
e.setInstant(Instant.now());
e.setZonedDateTime(ZonedDateTime.of(2019, 7, 18, 15, 05, 30, 0, ZoneId.of("UTC-4")));
em.persist(e);

Классы API даты и времени четко определяют, содержат ли они информацию о дате и/или времени. Поэтому спецификация JPA и все реализующие её фреймворки могут сопоставлять их с соответствующими SQL-типами.

11:52:26,305 DEBUG SQL:94 - insert into MyEntity (duration, instant, localDate, localDateTime, offsetDateTime, offsetTime, sqlDate, sqlTime, sqlTimestamp, utilCalendar, utilDate, zonedDateTime, id) values (?, ?, ?, ?, ?, ?, ?, ?)
11:52:26,306 TRACE BasicBinder:65 - binding parameter [1] as [BIGINT] - [PT2H]
11:52:26,307 TRACE BasicBinder:65 - binding parameter [2] as [TIMESTAMP] - [2019-07-22T09:52:26.284946300Z]
11:52:26,308 TRACE BasicBinder:65 - binding parameter [3] as [DATE] - [2019-07-19]
11:52:26,308 TRACE BasicBinder:65 - binding parameter [4] as [TIMESTAMP] - [2019-07-19T15:05:30]
11:52:26,312 TRACE BasicBinder:65 - binding parameter [5] as [TIMESTAMP] - [2019-07-19T15:05:30+02:00]
11:52:26,313 TRACE BasicBinder:65 - binding parameter [6] as [TIME] - [15:05:30+02:00]
11:52:26,324 TRACE BasicBinder:65 - binding parameter [7] as [TIMESTAMP] - [2019-07-18T15:05:30-04:00[UTC-04:00]]
11:52:26,324 TRACE BasicBinder:65 - binding parameter [8] as [BIGINT] - [1]

Работа с ZonedDateTime

Объект ZonedDateTime представляет дату с информацией о времени и часовом поясе, и тип JDBC TIMESTAMP_WITH_TIMEZONE кажется идеальным вариантом. К сожалению, не все реляционные базы данных поддерживают этот тип, и Hibernate приходится выполнять преобразование типа.

Как уже упоминалось, обработка ZonedDateTime зависит от версии Hibernate:

  • Hibernate 6 поддерживает различные варианты маппинга, которые дают полный контроль над обработкой часовых поясов.

  • Hibernate 5 нормализует часовой пояс объекта ZonedDateTime, что может вызывать проблемы.

Поддержка ZonedDateTime в Hibernate 6

В Hibernate 6 был введен enum TimeZoneStorageType. Он позволяет настроить, как Hibernate будет обрабатывать информацию о часовом поясе, и дает возможность избежать любых нормализаций часовых поясов.

Настройка обработки часовых поясов

Вы можете использовать значения этого enum, чтобы задать обработку часового пояса по умолчанию, установив свойство hibernate.timezone.default_storage в конфигурации persistence.xml.

<persistence>
    <persistence-unit name="my-persistence-unit">
        <description>Hibernate example configuration - thorben-janssen.com</description>
        <exclude-unlisted-classes>false</exclude-unlisted-classes>
        <properties>
            <property name="hibernate.timezone.default_storage" value="NORMALIZE"/>	
			...
        </properties>
    </persistence-unit>
</persistence>

Вы также можете аннотировать атрибут ZonedDateTime с помощью аннотации @TimeZoneStorage.

@Entity
public class MyEntity {
	@TimeZoneStorage(TimeZoneStorageType.NATIVE)
	private ZonedDateTime zonedDateTime;
	...
}

Типы TimeZoneStorageTypes в Hibernate

Вот краткий обзор различных типов TimeZoneStorageTypes, предоставляемых Hibernate. Более подробное описание каждого типа вы можете найти в этой статье.

TimeZoneStorageType

Version

JDBC Type

NATIVE

6.0

TIMESTAMP_WITH_TIMEZONE

NORMALIZE

6.0

TIMESTAMP

Приводит отметку времени к локальной или настроенной временной зоне (так же, как в Hibernate 5).

Используется по умолчанию в Hibernate 6.0 и 6.1.

NORMALIZE_UTC

6.0

TIMESTAMP

Приводит отметку времени к UTC.

COLUMN

6.0

TIMESTAMP + INTEGER

Сохраняет отметку времени и смещение временной зоны в отдельных столбцах.

AUTO

6.0

Зависит от диалекта базы данных:

TIMESTAMP_WITH_TIMEZONE если база данных поддерживает этот тип,

COLUMN в противном случае.

DEFAULT

6.2

Зависит от диалекта базы данных:

TIMESTAMP_WITH_TIMEZONE если база данных поддерживает этот тип,

NORMALIZE_UTC в противном случае.

Поддержка ZonedDateTime в Hibernate 5

Hibernate 5 отображает ZonedDateTime в SQL-тип TIMESTAMP без информации о часовом поясе.

Это достигается путем нормализации временной метки. Hibernate преобразует ZonedDateTime в локальный часовой пояс JVM и сохраняет его в базе данных. При чтении TIMESTAMP Hibernate добавляет информацию о локальном часовом поясе.

MyEntity e = new MyEntity();
e.setZonedDateTime(ZonedDateTime.of(2019, 7, 18, 15, 05, 30, 0, ZoneId.of("UTC-4")));
em.persist(e);

Hibernate 5 отображает информацию о часовом поясе в логах.

09:57:08,918 DEBUG SQL:92 - insert into MyEntity (zonedDateTime, id) values (?, ?)
09:57:08,959 TRACE BasicBinder:65 - binding parameter [1] as [TIMESTAMP] - [2019-07-18T15:05:30-04:00[UTC-04:00]]
09:57:08,961 TRACE BasicBinder:65 - binding parameter [2] as [BIGINT] - [1]

Но в базе данных видно, что он преобразовал часовой пояс с UTC-4 в UTC+2, который является моим локальным часовым поясом.

Этот маппинг работает при соблюдении следующих условий:

  • используется часовой пояс без учета летнего времени,

  • все экземпляры вашего приложения используют один и тот же часовой пояс,

  • вы никогда не меняете этот часовой пояс.

Очевидно, что это должно...

Вы можете избежать этих проблем, настроив часовой пояс без учета летнего времени в конфигурации persistence.xml. В таком случае Hibernate 5 будет использовать указанный часовой пояс вместо локального часового пояса вашей JVM. Я рекомендую использовать часовой пояс UTC.

<persistence>
    <persistence-unit name="my-persistence-unit">

        ...

        <properties>
            <property name="hibernate.dialect" value="org.hibernate.dialect.PostgreSQLDialect" />
            <property name="hibernate.jdbc.time_zone" value="UTC"/>

            ...

        </properties>
    </persistence-unit>
</persistence>

Когда вы повторно запустите тест, вы не увидите никаких изменений в логах.

10:06:41,070 DEBUG SQL:92 - insert into MyEntity (zonedDateTime, id) values (?, ?)
10:06:41,107 TRACE BasicBinder:65 - binding parameter [1] as [TIMESTAMP] - [2019-07-18T15:05:30-04:00[UTC-04:00]]
10:06:41,108 TRACE BasicBinder:65 - binding parameter [2] as [BIGINT] - [1]

Но если вы посмотрите на таблицу в базе данных, вы увидите, что Hibernate 5 теперь преобразовал ZonedDateTime в часовой пояс UTC.

Маппинг классов java.util

До выхода Java 8 классы java.util.Date и java.util.Calendar были наиболее используемыми для представления дат с и без информации о времени.

Конечно, вы можете маппить оба этих класса с помощью JPA и Hibernate. Однако для маппинга требуется указать несколько дополнительных данных. Нужно определить, хотите ли вы связать java.util.Date или java.util.Calendar со столбцом типа DATE, TIME или TIMESTAMP.

Для этого необходимо аннотировать атрибут сущности с помощью аннотации @Temporal и указать значение из enum TemporalType. Вы можете выбрать одно из следующих значений:

  • TemporalType.DATE — для маппинга в столбец SQL-типа DATE;

  • TemporalType.TIME — для маппинга в столбец SQL-типа TIME;

  • TemporalType.TIMESTAMP — для маппинга в столбец SQL-типа TIMESTAMP.

В следующем примере кода используется аннотация @Temporal для маппинга атрибута типа java.util.Date в столбец TIMESTAMP и атрибута типа java.util.Calendar в столбец DATE.

@Entity
public class MyEntity {

	@Temporal(TemporalType.TIMESTAMP)
	private Date utilDate;

	@Temporal(TemporalType.DATE)
	private Calendar utilCalendar;

	...

}

Затем вы можете использовать эти атрибуты так же, как и любые другие атрибуты сущности.

MyEntity e = new MyEntity();
e.setUtilDate(new Date(119, 6, 18));
e.setUtilCalendar(new GregorianCalendar(2019, 6, 18));
em.persist(e);

Если вы активируете логирование SQL-запросов, то в логах найдете следующие сообщения.

16:04:07,185 DEBUG SQL:92 - insert into MyEntity (utilCalendar, utilDate, id) values (?, ?, ?)
16:04:07,202 TRACE BasicBinder:65 - binding parameter [8] as [DATE] - [java.util.GregorianCalendar[time=1563400800000,areFieldsSet=true,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="Europe/Berlin",offset=3600000,dstSavings=3600000,useDaylight=true,transitions=143,lastRule=java.util.SimpleTimeZone[id=Europe/Berlin,offset=3600000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=2,startMonth=2,startDay=-1,startDayOfWeek=1,startTime=3600000,startTimeMode=2,endMode=2,endMonth=9,endDay=-1,endDayOfWeek=1,endTime=3600000,endTimeMode=2]],firstDayOfWeek=2,minimalDaysInFirstWeek=4,ERA=1,YEAR=2019,MONTH=6,WEEK_OF_YEAR=29,WEEK_OF_MONTH=3,DAY_OF_MONTH=18,DAY_OF_YEAR=199,DAY_OF_WEEK=5,DAY_OF_WEEK_IN_MONTH=3,AM_PM=0,HOUR=0,HOUR_OF_DAY=0,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=3600000,DST_OFFSET=3600000]]
16:04:07,207 TRACE BasicBinder:65 - binding parameter [2] as [TIMESTAMP] - [Thu Jul 18 00:00:00 CEST 2019]
16:04:07,208 TRACE BasicBinder:65 - binding parameter [3] as [BIGINT] - [1]

Второе сообщение о привязке объекта GregorianCalendar может вас удивить. Это довольно сложный способ Hibernate показать, какой объект Calendar привязывается к параметру типа DATE. Но не беспокойтесь: если вы посмотрите на базу данных, то увидите, что Hibernate записал дату в столбец типа DATE.

Маппинг классов java.sql

Маппинг классов java.sql.Date, java.sql.Time и java.sql.Timestamp выполняется просто, так как эти классы соответствуют SQL-типам данных. Это позволяет провайдеру JPA, например Hibernate, автоматически определить маппинг.

Таким образом, без дополнительных аннотаций:

  • java.sql.Date отображается в SQL-тип DATE,

  • java.sql.Time отображается в SQL-тип TIME,

  • java.sql.Timestamp отображается в SQL-тип TIMESTAMP.

@Entity
public class MyEntity {

	private java.sql.Date sqlDate;
	private Time sqlTime;
	private Timestamp sqlTimestamp;

	...

}

Затем вы можете использовать эти атрибуты в своей бизнес-логике для сохранения информации о дате и времени в базе данных.

MyEntity e = new MyEntity();
e.setSqlDate(new java.sql.Date(119, 6, 18));
e.setSqlTime(new Time(15, 05, 30));
e.setSqlTimestamp(new Timestamp(119, 6, 18, 15, 05, 30, 0));
em.persist(e);

А после активации логирования SQL-запросов вы можете увидеть, что Hibernate отображает атрибуты сущности в соответствующие SQL-типы.

06:33:09,139 DEBUG SQL:92 - insert into MyEntity (sqlDate, sqlTime, sqlTimestamp, id) values (?, ?, ?, ?)
06:33:09,147 TRACE BasicBinder:65 - binding parameter [1] as [DATE] - [2019-07-18]
06:33:09,147 TRACE BasicBinder:65 - binding parameter [2] as [TIME] - [15:05:30]
06:33:09,147 TRACE BasicBinder:65 - binding parameter [3] as [TIMESTAMP] - [2019-07-18 15:05:30.0]
06:33:09,154 TRACE BasicBinder:65 - binding parameter [4] as [BIGINT] - [1]

Заключение

JPA и Hibernate могут маппить столбцы базы данных типов DATE, TIME и TIMESTAMP на различные классы Java. Вы можете связать их с:

  • java.util.Date и java.util.Calendar;

  • java.sql.Date, java.sql.Time и java.sql.Timestamp;

  • java.time.LocalDate, java.time.LocalDateTime, java.time.OffsetTime, java.time.OffsetDateTime, java.time.ZonedDateTime, java.time.Duration.

Вам нужно лишь выбрать, какой тип Java использовать в своем коде. Я рекомендую использовать классы из пакета java.time. Они являются частью API даты и времени, который был представлен в Java 8. Эти классы значительно удобнее в использовании как для маппинга, так и в бизнес-логике.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Комментарии (0)