Данная статья является переводом анонса Гевина Кингa о релизе первого milestone JPA 4 спецификации и общего вектора развития JPA.

Полноценная 4-ая версия JPA спецификации должна выйти чуть-чуть позже в этом году. Тем не менее, то, что сейчас в Milestone это основа, которая, возможно, с небольшими доработками, но уже пойдёт в релиз в этом году.

От себя скажу, что данное обновление будет довольно крупное. Я оставил свои комментарии там, где посчитал уместным.

P.S: У нас в рамках Spring АйО Академии как раз есть программа посвящённая Hibernate. Набор мы на неё ведём до конца апреля.

В ней мы тоже затронем новую версию спецификации, в частности и работу со StatelessSession и EntityAgent на практике. Я думаю, что из всего апдейта, именно стандартизация работы без Persistence Context будет самым важным для прикладных разработчиков. Остальное тоже важно, но не так как работа с EntityAgent.

Приятного чтения!


Недавно мы выпустили первую milestone-сборку Jakarta Persistence 4.0.

Jakarta Persistence — чаще всего его называют JPA — задаёт отраслевой стандарт управления persistence и object/relational mapping в Java. Это самое распространённое решение для persistence в экосистеме Java и без сравнения самый успешный API object/relational mapping в любом языке программирования. JPA 4 — без преувеличения самая значимая переработка спецификации со времён выхода JPA 2.0 в декабре 2009 года.

Эта milestone-сборка предназначена для более широкого обзора сообществом с целью собрать отзывы пользователей и реализаторов. Помните: сейчас самое время присылать feedback. JPA 4 ещё не завершён по функциям, и ни одно из описанных ниже изменений не «высечено в гранит». Не ждите финала спецификации позже в этом году, чтобы попробовать.

Посмотрим, что нового в этом релизе.

EntityAgent

Мы обещали это годами — и наконец это здесь.

Типичные жалобы на object/relational mapping по сути сводятся к одному: для меньшинства разработчиков и для некоторых видов программ managed entities и stateful persistence context «не заходят». Иногда людям нужен более прямой контроль над взаимодействием с базой данных.

Если фундаментальные операции EntityManager (persist, remove, merge, detach, lock, flush) — это операции над persistence context и лишь косвенно затрагивают базу, то фундаментальные операции EntityAgent (insert, update, delete, upsert) обходят persistence context и напрямую меняют базу. Такая модель программирования проще для понимания и рассуждений о ней.

```
var book = factory.callInTransaction(EntityAgent.class, agent -> {
return agent.get(Book.class, isbn); // book is immediately detached
});

book.title = "Hibernate in Action"; // change it

factory.runInTransaction(EntityAgent.class, agent -> {
agent.update(book); // update the database immediately
});
```

Комментарий

Очень важно, что book в примере выше сразу же "DETACHED". Это означает, что dirty-checking не сработает, что логично, т.к. работа идёт в обход Session.

Как ни странно, на моей практике, это в продуктовой разработке чаще плюс, чем минус, т.к. часто люди как раз не надеются на dirty-check, а вызывают условный repository.save() у Spring Data JPA репозитория напрямую. Нечто похожее теперь придётся делать и в случае использования EntityAgent (собственно, в примере Гевин это и делает).

Ещё один важный момент. Имея сущность Book, у которой есть коллекция авторов, в традиционных Spring Data JPA приложениях разработчик немного побаивается вызвать что-то типа book.authors().size(), т.к. это может привести либо к (в лучшем случае) LIE, либо, что на самом деле хуже, к N + 1. В случае EntityAgent же отвественность на загрузку того или иного relation-a ляжет на приложение. И я думаю, что появление возможности такого контроля это явно плюс.

В общем, если подытожить, я думаю, что Гевин и команда делают однозначно нужное движение в сторону предоставления большего контроля над выполняемыми запросами в целом, делая разработчика ближе к БД.

Тем не менее, подождём реакции Spring Data JPA на эту спецификацию. В целом Spring Framework уже начал реализовывать поддержку EntityAgent. Эта поддержка будет заслуживать отдельной статьи.

Вместе с EntityAgent появился целый зоопарк новых низкоуровневых lifecycle events вроде @PreInsert, @PostUpsert, @PreDelete и т.д.

«Static» queries

Новые @StaticQuery и @StaticNativeQuery, а также @ReadQueryOptions и @WriteQueryOptions проектировались с прицелом на Jakarta Data на первом месте.

```
@Repository
interface Library {

 @StaticQuery("from Book where isbn = :isbn")
 @ReadQueryOptions(lockMode = PESSIMISTIC_READ)
 Book getBookWithIsbn(String isbn);

 @StaticQuery("from Book where title like :title")
 @ReadQueryOptions(cacheStoreMode = BYPASS)
 List<Book> findBooksByTitle(String title);

 @StaticQuery("delete from Trash")
 @WriteQueryOptions(timeout = 30_000)
 int emptyTrash();

}
```

Annotation processor вроде Hibernate Processor может делать тайп чекинг любого статического запроса относительно:

Ошибки в запросах вы узнаёте сразу, на этапе компиляции, без запуска кода.

Эти аннотации не привязаны к Jakarta Data. С помощью JPA static metamodel static query можно вызывать напрямую через EntityManager или EntityAgent типобезопасным способом.

var books = agent .createQuery(Library_.findBooksByTitle("%Jakarta%")) .getResultList();

int deleted = agent .createStatement(Library_.emptyTrash()) .execute();

Конечно, static queries не обязаны возвращать entities.

```
record Summary(String title, String isbn, LocalDate date) {}

@StaticQuery("""
select title, isbn, pubDate
from Book
where title like = ?1 and pubDate > ?2
""")
List

retrieveSummaries(String title, LocalDate fromDate);
```

```
import static org.example.Library_.retrieveSummaries;

var summaries =
manager.createQuery(retrieveSummaries("%JPA%",
LocalDate.of(2006,5,11)))
.getResultList();
```

Static queries открывают гораздо большую часть возможностей JPA, чем обычно доступно при repository-подобной абстракции. О static queries можно сказать ещё много, но нужно двигаться дальше.

Programmatic result set mappings

Аннотация @SqlResultSetMapping существует с JPA 1.0. Она помогает выразить более сложное отображение native SQL result set на Java-объекты. Но аннотации для этой задачи всегда казались мне не идеальной парой, и я годами обещал альтернативу. Она наконец пришла в виде API ResultSetMapping.

В следующем примере явный result set mapping на самом деле не нужен — это только иллюстрация идеи.

var authorResultSetMapping = entity(Author.class, field(Author_.ssn, "auth_ssn"), embedded(Author_.name, field(Name_.first, "auth_first_name"), field(Name_.last, "auth_last_name"))); var query = """select ssn as auth_ssn, fn as auth_first_name, ln as auth_last_name from authors"""; var authors = agent.createNativeQuery(query, authorResultSetMapping) .getResultList();

Конечно, native SQL query не обязан возвращать entities.

var constructorMapping = constructor(Summary.class, column("isbn", String.class), column("title", String.class), column("author", String.class)); var query = "select b.isbn, b.title, a.name" + " from books b" + " join book_author ba on ba.isbn = b.isbn" + " join authors a on ba.ssn = a.ssn"; var summaries = manager.createNativeQuery(query, constructorMapping) .getResultList();

Новый API приятен и типобезопасен — снова благодаря static metamodel.

С другой стороны, предположим, у нас уже есть result set mapping, заданный через почтенную аннотацию @SqlResultSetMapping.

@SqlResultSetMapping( name = "orderResults", entities = @EntityResult( entityClass = Order.class, fields = { @FieldResult(name = "id", column = "order_id"), @FieldResult(name = "total", column = "order_total"), @FieldResult(name = "item", column = "order_item") } ), columns = @ColumnResult(name = "item_name") ) @Entity class Order { ... }

Новый API позволяет обращаться к таким mappings типобезопасно — опять же через static metamodel.

var orders = entityManager.createNativeQuery( """ SELECT o.id AS order_id, o.total AS order_total, o.item_id AS order_item, i.desc_name AS item_name FROM orders o, order_items i WHERE order_total > 25 AND order_item = i.id """, // a typesafe reference to the result set mapping Order_._orderResults ).getResultList();

Default fetch type для to-one associations

Самая серьёзная ошибка в дизайне JPA 1.0 — сделать для ассоциаций @ManyToOne и @OneToOne значение по умолчанию fetch=EAGER. Страшно думать, сколько миллионов долларов (вполне правдоподобно — миллиард) эта одна плохая настройка по умолчанию стоила индустрии с мая 2006 года. Когда кто-то жалуется, что Hibernate или JPA генерируют запросы с кучей JOIN-ов, мне хочется крикнуть в монитор: «нет, вы должны выставить LAZY!» И я знаю: почти полностью это моя вина — я молчал и соглашался с тем, что знал неверным.

Теперь можно сделать LAZY значением по умолчанию на уровне persistence unit:

<default-to-one-fetch-type>LAZY<default-to-one-fetch-type>

или:

persistenceConfiguration.defaultToOneFetchType(FetchType.LAZY)

Пожалуйста, делайте так в каждом новом проекте.

Комментарий

Тут два момента.

  1. Обратите внимание, что дефолт для @ManyToOne / @OneToOneне поменяется (с целью упрощения миграции, т.к. иначе оргомное кол-во приложений, кто надеялись на EAGER просто сломаются)
  2. И второе - вводитcя новый FetchType.DEFAULT. Делается это опять же с целью, чтобы миграция была планомерной. Этот change binary-compatible и behaviorly backward compatible тоже.

Но вот это мне кажется страшно, т.к. теперь, смотря на код, Вы не сможите понять, какой же в итоге фетч тайп-то?! EAGER или LAZY? Вам теперь надо будет понимать, какой дефолт выставлен в Persistence Unit, который конечно же Вы руками не настраиваете, вы надеятесь на Spring Boot авто-конифгурацию для JPA. Короче вот тут посмотрим что получится.

Statement и TypedQuery

С этим изменением мы долго боролись.

JPA 1.0 проектировался до появления generics в Java. Поддержку generics «достроили» в JPA 2.0, и, к сожалению, мы ошиблись, оставив Query, возвращающий raw List, и введя TypedQuery как generic-подтип. Из-за этого вызовы Query.getResultList() дают предупреждения компилятора и риск "unchecked casts". Тогда мотивом была обратная совместимость с клиентами под JPA 1.0, но честно — это можно было решить лучше. Конечно, тогда никто по-настоящему не понимал Java generics — всё было новым, такие ошибки были почти неизбежны.

К тому же у JPA не было отдельного API для выполнения update и delete statements, и со временем мы пришли к мысли, что оно должно быть.

С учётом этого сделано следующее:

  • введён интерфейс Statement для выполнения запросов без результата;
  • использование Query для прямого выполнения statements и queries помечено как deprecated;
  • Мы более четко прописали, что настоящие queries — это statement-ы, возвращающие результаты. Их следует выполнять через TypedQuery (вы кстати и так должны были так делать, чтобы избежать предупреждений компилятора).

К счастью, путь миграции с deprecated-модели простой.

Было:

List<Book> books = // ouch, unchecked cast em.createQuery("from Book where extract(year from publicationDate) > :year") .setParameter("year", Year.of(2000)) // now deprecated .setMaxResults(10) // now deprecated .setCacheRetrieveMode(CacheRetrieveMode.BYPASS) // now deprecated .getResultList(); // ouch, compiler warning

Достаточно одной строки:

List<Book> books = em.createQuery("from Book where extract(year from publicationDate) > :year") .ofType(Book.class) // just add this .setParameter("year", Year.of(2000)) .setMaxResults(10) .setCacheRetrieveMode(CacheRetrieveMode.BYPASS) .getResultList();

Или для statement:

int updated = em.createQuery("delete from Temporary where timestamp > ?1") .setParameter(1, cutoffDateTime) // now deprecated .executeUpdate(); // now deprecated

Две строки:

int updated = em.createQuery("delete from Temporary where timestamp > ?1") .asStatement() .setParameter(1, cutoffDateTime) .execute();

Или просто:

int updated = em.createStatement("delete from Temporary where timestamp > ?1") .setParameter(1, cutoffDateTime) .execute();

Мы понимаем, что подстраивать старый код под такие изменения раздражает. Но делать это срочно не обязательно: в жизненном цикле JPA 4 мы ничего не будем реально удалять. Такие правки довольно прямолинейно автоматизировать, например, чем-то вроде OpenRewrite.

get(), findMultiple() и getMultiple()

Операция find() у EntityManager или EntityAgent загружает entity по primary key и возвращает null, если entity не найдена. Иногда это полезно, но чаще отсутствие entity — ошибка, которую нужно сигнализировать через EntityNotFoundException. Именно это делает новый метод get(). Используйте get() вместо find(), если только ваш код явно не обрабатывает null от find().

Новые методы findMultiple() и getMultiple() по смыслу аналогичны find() и get() соответственно, но принимают списки primary keys и позволяют эффективно получить «пакет» entities.

List<Book> getBooks(List<String> isbns) { return agent.getMultiple(Book.class, isbns, CacheRetrieveMode.BYPASS, LockModeType.OPTIMISTIC); }

getResultCount()

Давно запрашиваемый новый метод TypedQuery.getResultCount() возвращает общее число результатов query.

setParameter() и setConvertedParameter()

Иногда полезно явно указать тип query parameter при передаче значения. Для JPQL queries это обычно не нужно, но встречается в native SQL statements и queries, где тип parameter нельзя вывести из самого запроса.

manager.createNativeStatement("update books set pub_date = :date where isbn = :ISBN") .setParameter("date", optionalPublicationDate, LocalDate.class) .setParameter("ISBN", isbn) .execute();

Optional select new

Язык запросов в JPA 4 (его переопределяют как Jakarta Query) больше не требует синтаксиса select new для queries, возвращающих проекции через простой Java record. Вместо этого record-ы можно указать как query result class.

```
record Summary(String title, String isbn, LocalDate date) {}

var summaries =
agent.createQuery(
"""
select title, isbn, pubDate
from Book
where title like = ?1 and pubDate > ?2
""")
.ofType(Summary.class)
.setParameter(1, titlePattern)
.setParameter(2, minDate)
.getResultList();
```

Пример мы уже видели при разговоре о статических запросах выше.

@ExcludedFromVersioning

Иногда полезно разрешить изменять отдельное поле entity без optimistic version check. Это означает допустимость lost updates для этого поля и может повысить concurrency. Реализации позволяли это давно; теперь это стандартизовано аннотацией @ExcludedFromVersioning.

StoredProcedureQuery

Интерфейс StoredProcedureQuery получил ряд существенных улучшений для более типобезопасного вызова stored procedures. Здесь я их не разбираю.

Programmatic registration слушателей entity lifecycle callbacks

Теперь можно зарегистрировать listener для типа entity lifecycle event во время выполнения.

factory.addListener(Book.class, PostPersist.class, book -> System.out.println("Book persisted: " + book.title));

Это особенно интересно разработчикам библиотек и фреймворков.

Для прикладных разработчиков, возможно, важнее другое: у одного entity listener class теперь может быть несколько методов-слушателей одного типа callback — например, несколько @PrePersist для разных типов entities.

Named entity graphs

Раньше named entity graph всегда задавался сложными вложенными аннотациями, которые по имени ссылались на поля entity, входящие в graph. Так по-прежнему можно, но в JPA 4 есть альтернатива: аннотировать включаемые поля graph’ом, к которому они относятся.

```
@NamedEntityGraph(name = "EmployeeWithProjectTasksAndEmployer")
@Entity // root entity of graph
public class Employee {
...
// fetched attribute node
@NamedEntityGraphAttributeNode(graph = "EmployeeWithProjectTasksAndEmployer")
@ManyToOne(fetch=LAZY) Employer employer;

 // reference to subgraph defined in Project class
 @NamedEntityGraphSubgraph(graph = "EmployeeWithProjectTasksAndEmployer")
 @ManyToMany Set<Project> projects;

}

@NamedEntityGraph(name = "EmployeeWithProjectTasksAndEmployer")
@Entity // root entity of subgraph
public class Project {
...
// reference to subgraph defined in Task class
@NamedEntityGraphSubgraph(graph = "EmployeeWithProjectTasksAndEmployer")
@OneToMany List tasks;
}
```

Named entity graphs по-прежнему далеки от того, чтобы стать моей любимой фичей JPA, но для тех, кто ими пользуется, это, пожалуй, заметное улучшение API.

Data loading и Schema Export

В SchemaManager добавлен метод populate() — чтобы удобно заполнять schema данными из DML script.

Метод PersistenceConfiguration.exportSchema() позволяет выполнять действия по управлению schema до инстанцирования EntityManagerFactory.

Pessimistic lock scopes

PessimisticLockScope появился в JPA 2.1 и позволял просить реализацию JPA взять pessimistic lock не только на entity, но и на всё связанное состояние в join tables и collection tables. Честно говоря, я так и не до конца понял эту фичу и не представляю, когда бы ею пользовался. Для всех, кроме самых тривиальных entities, это блокирует слишком много, включая строки, которые вы даже не читаете.

Выбирая между простым deprecation этой фичи и превращением её во что-то полезнее, мы пошли по второму пути. Новая опция PessimisticLockScope.FETCHED блокирует строки join tables и collection tables только если вы реально читаете их и подтягиваете их состояние в рамках операции, которая получает lock.

JDBC fetch size и batch size

Свойства jakarta.persistence.jdbc.fetchSize и jakarta.persistence.jdbc.batchSize теперь формально описаны спецификацией как стандартный способ управлять fetch size и batch size соответственно. Это важно, потому что у одного JDBC driver-а для одной очень важной базы плохое значение по умолчанию.

Изменения в контракте container/provider

Мы изменили SPI, регулирующие контракт между Persistence Provider-ом (т.е. Hibernate) и контейнером Jakarta EE, чтобы:

  • контейнер мог полностью взять на себя discovery классов, связанных с Persistence, в persistence unit — он делает это эффективнее, чем Persistence Provider;
  • разделить процесс class loading и enhancement от инстанцирования EntityManagerFactory, решая колючую проблему интеграции между CDI BeanManager и PersistenceProvider.

Specification

Сама спецификация — критически важная документация и для пользователей, и для реализаций. В рамках постоянной работы над читаемостью spec-и переработаны и переписаны большинство разделов первых двух глав и отдельные фрагменты по всему документу.

Связь с Jakarta Data и Jakarta Query

Важно поставить работу над Jakarta Persistence 4 в контекст. Описанное здесь — часть более крупного усилия, охватывающего три спецификации: Persistence, Query и Data. Компоненты спроектированы так, чтобы идеально стыковаться и давать бесшовный переход между более декларативной repository-моделью Jakarta Data и более программной моделью Jakarta Persistence без потери type safety.

Надеюсь, вам это понравится!

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