Статьи

Работа с Java LocalDateTime в JPA

Несколько дней назад я столкнулся с проблемой при работе с атрибутом 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 .