Статьи

Факты гибернации: всегда проверяйте Criteria API SQL-запросы

Criteria API очень полезен для динамического построения запросов, но это единственный вариант использования, в котором я бы его использовал. Всякий раз, когда у вас есть пользовательский интерфейс с N фильтрами, которые могут появиться в любых M комбинациях, имеет смысл иметь API для динамического построения запросов, поскольку объединение строк — это всегда путь, от которого я убегаю.

Вопрос в том, знаете ли вы о SQL-запросах, которые ваш Criteria API генерирует за кулисами? В последнее время я проверял много таких запросов, и меня поразило, как легко это сделать неправильно.

Давайте начнем со следующей диаграммы сущностей:

товар

Таким образом, у нас есть Продукт с ассоциацией ToOne для WareHouseProductInfo и ассоциацией ToMany с сущностью Image.

Теперь давайте начнем с этого запроса Criteria API:

1
2
3
4
5
6
7
8
9
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> query = cb.createQuery(Product.class);
Root<Product> productRoot = query.from(Product.class);
 
query.select(productRoot)
     .where(cb.and(cb.equal(productRoot.get(Product_.code), "tvCode"),
                   cb.gt(productRoot.get(Product_.warehouseProductInfo)
.get(WarehouseProductInfo_.quantity), 50)));
Product product = entityManager.createQuery(query).getSingleResult();

Можете ли вы обнаружить какие-либо проблемы с этим предыдущим запросом? Давайте проверим сгенерированный SQL:

01
02
03
04
05
06
07
08
09
10
11
SELECT product0_.id          AS id1_14_,
       product0_.code        AS code2_14_,
       product0_.company_id  AS company_5_14_,
       product0_.importer_id AS importer6_14_,
       product0_.name        AS name3_14_,
       product0_.version     AS version4_14_
FROM   product product0_
       CROSS JOIN warehouseproductinfo warehousep1_
WHERE  product0_.id = warehousep1_.id
       AND product0_.code = ?
       AND warehousep1_.quantity > 50

Я ожидал ВНУТРЕННЕГО СОЕДИНЕНИЯ, и вместо этого я получил КРОСС-СОЕДИНЕНИЕ. Декартово произведение очень неэффективно, и это то, что вы получите, если не забудете правильно присоединиться к ассоциациям, с которыми вы заинтересованы в фильтрации по предложениям where. Итак, написание Criteria API — это не прогулка по парку.

К счастью, этот пример можно исправить следующим образом:

1
2
3
4
5
6
7
8
9
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> query = cb.createQuery(Product.class);
Root<Product> productRoot = query.from(Product.class);
Join<Product, WarehouseProductInfo> warehouseProductInfoJoin = productRoot.join(Product_.warehouseProductInfo);
 
query.select(productRoot)
     .where(cb.and(cb.equal(productRoot.get(Product_.code), "tvCode"),
                   cb.gt(warehouseProductInfoJoin.get(WarehouseProductInfo_.quantity), 50)));
Product product = entityManager.createQuery(query).getSingleResult();

который дает ожидаемый запрос SQL:

01
02
03
04
05
06
07
08
09
10
11
SELECT product0_.id          AS id1_14_,
       product0_.code        AS code2_14_,
       product0_.company_id  AS company_5_14_,
       product0_.importer_id AS importer6_14_,
       product0_.name        AS name3_14_,
       product0_.version     AS version4_14_
FROM   product product0_
       INNER JOIN warehouseproductinfo warehousep1_
               ON product0_.id = warehousep1_.id
WHERE  product0_.code = ?
       AND warehousep1_.quantity > 50

Так что будьте осторожны с тем, как вы определяете свои объединения в Criteria API. Теперь давайте сравним предыдущий запрос Criteria API с его аналогом JPAQL:

01
02
03
04
05
06
07
08
09
10
Product product = entityManager.createQuery(
    "select p " +
    "from Product p " +
    "inner join p.warehouseProductInfo w " +
    "where " +
    "   p.code = :code and " +
    "   w.quantity > :quantity ", Product.class)
.setParameter("code", "tvCode")
.setParameter("quantity", 50)
.getSingleResult();

Я всегда считал JPAQL более описательным, чем Criteria API, но есть проекты, в которых Criteria API является механизмом запросов JPA по умолчанию, поэтому он используется не только для запросов динамических фильтров, но даже для тех, у которых фиксированные предложения where.

Что ж, в конечном итоге вы можете достичь тех же результатов, но, хотя я могу предсказать SQL-запрос из JPAQL-запроса, но когда дело доходит до Criteria API, я совершенно не в курсе. Всякий раз, когда я рассматриваю запрос Criteria, мне всегда нужно запускать интеграционный тест, чтобы проверить выводимый SQL, поскольку небольшие изменения могут действительно иметь большие различия.

Даже если использование Criteria API навязано, вы все равно можете обойти его, считая, что вы очень осторожны и просматриваете все свои запросы.

Теперь давайте вернемся к одному из самых экзотических (но все же неоптимальных) критериев присоединения, который я недавно наткнулся на. Если вы работаете над большим проектом со многими разработчиками, вы неизбежно столкнетесь с этим типом конструкций. Это еще одна причина, по которой я предпочитаю JPAQL над Criteria API. С JPAQL вы не можете получить его так, как в следующем примере:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> query = cb.createQuery(Product.class);
Root<Product> product = query.from(Product.class);
query.select(product);
query.distinct(true);
 
List<Predicate> criteria = new ArrayList<Predicate>();
criteria.add(cb.like(cb.lower(product.get(Product_.name)), "%tv%"));
 
Subquery<Long> subQuery = query.subquery(Long.class);
Root<Image> infoRoot = subQuery.from(Image.class);
Join<Image, Product> productJoin = infoRoot.join("product");
subQuery.select(productJoin.<Long>get(Product_.id));
 
subQuery.where(cb.gt(infoRoot.get(Image_.index), 0));
criteria.add(cb.in(product.get(Product_.id)).value(subQuery));
query.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
return entityManager.createQuery(query).getResultList();

Я нахожу эти типы запросов слишком сложными для анализа, только просматривая их, но есть подвыбор, пахнущий как проблема, поэтому давайте посмотрим на сгенерированный запрос SQL:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
SELECT DISTINCT product0_.id          AS id1_14_,
                product0_.code        AS code2_14_,
                product0_.company_id  AS company_5_14_,
                product0_.importer_id AS importer6_14_,
                product0_.name        AS name3_14_,
                product0_.version     AS version4_14_
FROM   product product0_
WHERE  ( Lower(product0_.name) LIKE ? )
       AND ( product0_.id IN (SELECT product2_.id
                              FROM   image image1_
                                     INNER JOIN product product2_
                                             ON image1_.product_id =
                                                product2_.id
                              WHERE  image1_.index > 0) )

Хотя некоторые сценарии использования требуют подзапроса SQL, здесь он просто совершенно не нужен и только замедляет ваш запрос. Но на этот раз нам фактически требовался запрос динамической фильтрации, поэтому JPAQL не обсуждался. Единственный способ исправить это — написать правильный запрос Criteria.

Так вот, после рефакторинга:

01
02
03
04
05
06
07
08
09
10
11
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> query = cb.createQuery(Product.class);
Root<Image> imageRoot = query.from(Image.class);
Join<Image, Product> productJoin = imageRoot.join("product");
query.select(productJoin);
query.distinct(true);
List<Predicate> criteria = new ArrayList<Predicate>();
criteria.add(cb.like(cb.lower(productJoin.get(Product_.name)), "%tv%"));
criteria.add(cb.gt(imageRoot.get(Image_.index), 0));
query.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
return entityManager.createQuery(query).getResultList();

И теперь наш SQL-запрос выглядит намного лучше:

01
02
03
04
05
06
07
08
09
10
11
SELECT DISTINCT product1_.id          AS id1_14_,
                product1_.code        AS code2_14_,
                product1_.company_id  AS company_5_14_,
                product1_.importer_id AS importer6_14_,
                product1_.name        AS name3_14_,
                product1_.version     AS version4_14_
FROM   image image0_
       INNER JOIN product product1_
               ON image0_.product_id = product1_.id
WHERE  ( Lower(product1_.name) LIKE ? )
       AND image0_.index > 0

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

Теперь давайте сделаем прогноз DTO, поскольку бывают случаи, когда нам не нужно извлекать целые сущности, а достаточно информации для удовлетворения потребностей нашего бизнеса. На этот раз мы создадим следующий запрос:

01
02
03
04
05
06
07
08
09
10
11
12
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<ImageProductDTO> query = cb.createQuery(ImageProductDTO.class);
Root<Image> imageRoot = query.from(Image.class);
Join<Image, Product> productJoin = imageRoot.join(Image_.product);
query.distinct(true);
List<Predicate> criteria = new ArrayList<Predicate>();
criteria.add(cb.like(cb.lower(productJoin.get(Product_.name)), "%tv%"));
criteria.add(cb.gt(imageRoot.get(Image_.index), 0));
query.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
query.select(cb.construct(ImageProductDTO.class, imageRoot.get(Image_.name), productJoin.get(Product_.name)))
        .orderBy(cb.asc(imageRoot.get(Image_.name)));
return entityManager.createQuery(query).getResultList();

Генерация чистого SQL:

1
2
3
4
5
6
7
8
SELECT DISTINCT image0_.name   AS col_0_0_,
                product1_.name AS col_1_0_
FROM   image image0_
       INNER JOIN product product1_
               ON image0_.product_id = product1_.id
WHERE  ( Lower(product1_.name) LIKE ? )
       AND image0_.index > 0
ORDER  BY image0_.name ASC

Но посмотрите предыдущий Criteria Query, чтобы узнать, как JOOQ создает такой запрос:

1
2
3
4
5
6
7
8
jooqContext
.select(IMAGE.NAME, PRODUCT.NAME)
.from(IMAGE)
.join(PRODUCT).on(IMAGE.PRODUCT_ID.equal(PRODUCT.ID))
.where(PRODUCT.NAME.likeIgnoreCase("%tv%"))
    .and(IMAGE.INDEX.greaterThan(0))
.orderBy(IMAGE.NAME.asc())
.fetch().into(ImageProductDTO.class);

Это намного удобнее для чтения, вам не нужно угадывать, каков выходной SQL-запрос, и он даже генерирует параметры привязки, которые я считаю чрезвычайно ценными:

1
2
3
4
5
6
7
8
SELECT "PUBLIC"."image"."name",
       "PUBLIC"."product"."name"
FROM   "PUBLIC"."image"
       JOIN "PUBLIC"."product"
         ON "PUBLIC"."image"."product_id" = "PUBLIC"."product"."id"
WHERE  ( Lower("PUBLIC"."product"."name") LIKE Lower('%tv%')
         AND "PUBLIC"."image"."index" > 0 )
ORDER  BY "PUBLIC"."image"."name" ASC

Вывод

Первый случай, который я вам показал, — одна из самых первых ошибок, которые я совершил, пытаясь изучить Criteria API. Я обнаружил, что при написании таких запросов я должен быть особенно осторожен, поскольку вы можете легко получить неожиданные запросы SQL.

Если вы решили использовать Criteria API для всех своих запросов, то вам может быть интересно проверить JOOQ . Даже если вы выберете JPAQL, всякий раз, когда вы захотите создавать расширенные динамически фильтрованные запросы, JOOQ может помочь вам в этом.

Вы по-прежнему будете использовать свободный API, вы не будете писать какие-либо строки и получите больше возможностей SQL, чем то, что в настоящее время предлагает Hibernate. Поэтому, когда ваши варианты использования не требуют запросов к управляемым объектам, вы можете использовать вместо них JOOQ. Мне это нравится, потому что я могу предсказать сгенерированный SQL намного лучше, чем с Criteria API, и когда API легче использовать, меньше «сюрпризов» ждут, чтобы «удивить» вас.

Код доступен на GitHub .