Несколько дней назад я столкнулся с проблемой при работе с атрибутом LocalDateTime в JPA. В этом посте я попытаюсь создать пример проблемы, чтобы объяснить проблему, а также решение, которое я использовал.
Рассмотрим следующую сущность, которая моделирует сотрудника определенной компании:
01
02
03
04
05
06
07
08
09
10
11
12
|
@Entity @Getter @Setter public class Employee { @Id @GeneratedValue private Long id; private String name; private String department; private LocalDateTime joiningDate; } |
Я использовал Spring Data JPA, поэтому создал следующий репозиторий —
1
2
3
4
5
|
@Repository public interface EmployeeRepository extends JpaRepository<Employee, Long> { } |
Я хотел найти всех сотрудников, которые присоединились к компании на определенную дату. Для этого я расширил свой репозиторий из
JpaSpecificationExecutor —
1
2
3
4
5
6
|
@Repository public interface EmployeeRepository extends JpaRepository<Employee, Long>, JpaSpecificationExecutor<Employee> { } |
и написал запрос, как показано ниже —
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
@SpringBootTest @RunWith (SpringRunner. class ) @Transactional public class EmployeeRepositoryIT { @Autowired private EmployeeRepository employeeRepository; @Test public void findingEmployees_joiningDateIsZeroHour_found() { DateTimeFormatter formatter = DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" ); LocalDateTime joiningDate = LocalDateTime.parse( "2014-04-01 00:00:00" , formatter); Employee employee = new Employee(); employee.setName( "Test Employee" ); employee.setDepartment( "Test Department" ); employee.setJoiningDate(joiningDate); employeeRepository.save(employee); // Query to find employees List<Employee> employees = employeeRepository.findAll((root, query, cb) -> cb.and( cb.greaterThanOrEqualTo(root.get(Employee_.joiningDate), joiningDate), cb.lessThan(root.get(Employee_.joiningDate), joiningDate.plusDays( 1 ))) ); assertThat(employees).hasSize( 1 ); } } |
Вышеуказанный тест прошел без проблем. Однако следующий тест не удался (который должен был пройти) —
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
|
@Test public void findingEmployees_joiningDateIsNotZeroHour_found() { DateTimeFormatter formatter = DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" ); LocalDateTime joiningDate = LocalDateTime.parse( "2014-04-01 08:00:00" , formatter); LocalDateTime zeroHour = LocalDateTime.parse( "2014-04-01 00:00:00" , formatter); Employee employee = new Employee(); employee.setName( "Test Employee" ); employee.setDepartment( "Test Department" ); employee.setJoiningDate(joiningDate); employeeRepository.save(employee); List<Employee> employees = employeeRepository.findAll((root, query, cb) -> cb.and( cb.greaterThanOrEqualTo(root.get(Employee_.joiningDate), zeroHour), cb.lessThan(root.get(Employee_.joiningDate), zeroHour.plusDays( 1 )) ) ); assertThat(employees).hasSize( 1 ); } |
Единственное, что отличается от предыдущего теста, это то, что в предыдущем тесте я использовал нулевой час в качестве даты присоединения, а здесь я использовал 8 часов утра. Сначала это показалось мне странным. Испытания, казалось, проходили всякий раз, когда дата присоединения сотрудника была установлена на ноль часов в день, но терпели неудачу, когда она была установлена в любое другое время.
Чтобы исследовать проблему, я включил запись в спящий режим, чтобы увидеть фактический запрос и значения, отправляемые в базу данных, и заметил что-то подобное в журнале —
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
2017-03-05 22:26:20.804 DEBUG 8098 --- [ main] org.hibernate.SQL: select employee0_.id as id1_0_, employee0_.department as departme2_0_, employee0_.joining_date as joining_3_0_, employee0_. name as name4_0_ from employee employee0_ where employee0_.joining_date>=? and employee0_.joining_dateHibernate: select employee0_.id as id1_0_, employee0_.department as departme2_0_, employee0_.joining_date as joining_3_0_, employee0_. name as name4_0_ from employee employee0_ where employee0_.joining_date>=? and employee0_.joining_date2017-03-05 22:26:20.806 TRACE 8098 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARBINARY] - [2014-04-01T00:00] 2017-03-05 22:26:20.807 TRACE 8098 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [VARBINARY] - [2014-04-02T00:00] |
Было очевидно, что JPA НЕ рассматривал атрибут joiningDate как дату или время, а как тип VARBINARY . Вот почему сравнение с фактической датой провалилось.
На мой взгляд, это не очень хороший дизайн. Вместо того, чтобы выдавать что-то вроде UnsupportedAttributeException или чего-то еще, он молча пытался преобразовать значение во что-то другое, и, таким образом, провал сравнения случайным образом (ну, не совсем случайным). Ошибки такого типа трудно найти в приложении, если у вас нет сильного набора автоматических тестов, что, к счастью, было моим случаем.
Вернемся к проблеме сейчас. Причина, по которой JPA не удалось соответствующим образом преобразовать LocalDateTime, была очень проста. Последняя версия спецификации JPA (то есть 2.1) была выпущена до Java 8, и в результате она не может обрабатывать новый API даты и времени.
Чтобы решить эту проблему, я создал собственную реализацию конвертера, которая преобразует LocalDateTime в
java.sql. Timestamp перед сохранением его в базу данных, и наоборот. Это решило проблему —
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
@Converter (autoApply = true ) public class LocalDateTimeConverter implements AttributeConverter<LocalDateTime, Timestamp> { @Override public Timestamp convertToDatabaseColumn(LocalDateTime localDateTime) { return Optional.ofNullable(localDateTime) .map(Timestamp::valueOf) .orElse( null ); } @Override public LocalDateTime convertToEntityAttribute(Timestamp timestamp) { return Optional.ofNullable(timestamp) .map(Timestamp::toLocalDateTime) .orElse( null ); } } |
Приведенный выше конвертер будет автоматически применяться всякий раз, когда я пытаюсь сохранить атрибут LocalDateTime. Я мог также явно пометить атрибуты, которые я хотел преобразовать явно, используя
javax.persistence.Convert annotation —
1
2
|
@Convert (converter = LocalDateTimeConverter. class ) private LocalDateTime joiningDate; |
Полный код доступен на Github .
Ссылка: | Работа с Java LocalDateTime в JPA от нашего партнера по JCG Саима Ахмеда в блоге Codesod . |