Querydsl и JPA Specifications: проверьте запрос до смены инструмента
Выбирайте инструмент запросов по тому, что нужно выразить и проверить. Пауза между выпусками может стать поводом для исследования, но не доказывает, что замена сохранит поведение запросов приложения.
Исходная проблема 2023 года возникла до последующего выпуска Querydsl 5.1.0. Тег подтверждает существование выпуска; отдельно оцените репозиторий, координаты артефактов и совместимость ветки, которую собираетесь использовать. Пример ниже закрепляет среду воспроизведения, а не рекомендует актуальную производственную версию.
Сравните характерный предикат
Загрузите одни и те же пять строк, создайте оба предиката и проверьте явно заданные ожидаемые результаты. Тесты охватывают эту сущность в H2. Решение о миграции также требует проверки соединений, проекций, пагинации и поведения БД, реально используемых приложением.
Spring Data JPA Specifications объединяют предикаты Criteria и выполняют их через JpaSpecificationExecutor. Для простых фильтров репозитория этого может хватить без замены модели хранения JPA.
Добавьте три файла в пример Querydsl
Начните со всех пяти файлов полного проекта Jakarta Querydsl, включая Employee.getName(). Его среда воспроизведения использует Java17, Gradle8.4, Boot3.5.7, Querydsl5.1.0 Jakarta и H2. Добавьте следующие три полных файла; не смешивайте эту конфигурацию с отдельными примерами Boot4 из руководства.
src/main/java/example/EmployeeRepository.java
package example;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface EmployeeRepository extends JpaRepository<Employee, Long>,
JpaSpecificationExecutor<Employee> {}
src/main/java/example/EmployeeSpecs.java
package example;
import org.springframework.data.jpa.domain.Specification;
public final class EmployeeSpecs {
private EmployeeSpecs() {}
public static Specification<Employee> named(String name) {
if (name == null) throw new IllegalArgumentException("Use withoutName for null");
return (root, query, builder) -> builder.equal(root.get("name"), name);
}
public static Specification<Employee> withoutName() {
return (root, query, builder) -> builder.isNull(root.get("name"));
}
}
src/test/java/example/QueryStrategyTest.java
package example;
import java.util.Arrays;
import java.util.List;
import com.querydsl.core.types.Predicate;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import static org.assertj.core.api.Assertions.*;
@DataJpaTest
class QueryStrategyTest {
@Autowired TestEntityManager entityManager;
@Autowired EmployeeRepository employees;
final QEmployee employee = QEmployee.employee;
@BeforeEach
void seed() {
for (String name : Arrays.asList("Alice", "Bob", "Alice", "A_%", null)) {
entityManager.persist(new Employee(name));
}
entityManager.flush();
}
void equivalent(Predicate predicate, Specification<Employee> specification,
List<String> expected) {
var querydsl = new JPAQueryFactory(entityManager.getEntityManager())
.select(employee.name).from(employee).where(predicate)
.orderBy(employee.name.asc()).fetch();
var criteria = employees.findAll(specification, Sort.by("name"))
.stream().map(Employee::getName).toList();
assertThat(querydsl).containsExactlyElementsOf(expected);
assertThat(criteria).containsExactlyElementsOf(expected);
}
@Test
void exactEqualityPreservesDuplicates() {
equivalent(employee.name.eq("Alice"), EmployeeSpecs.named("Alice"),
List.of("Alice", "Alice"));
}
@Test
void missingValueReturnsNoRows() {
equivalent(employee.name.eq("Missing"), EmployeeSpecs.named("Missing"), List.of());
}
@Test
void wildcardCharactersAreLiteralForEquality() {
equivalent(employee.name.eq("A_%"), EmployeeSpecs.named("A_%"), List.of("A_%"));
}
@Test
void andCanEliminateAllRows() {
equivalent(employee.name.eq("Alice").and(employee.name.eq("Bob")),
EmployeeSpecs.named("Alice").and(EmployeeSpecs.named("Bob")), List.of());
}
@Test
void orKeepsMatchesAndDuplicatesInOrder() {
equivalent(employee.name.eq("Alice").or(employee.name.eq("Bob")),
EmployeeSpecs.named("Alice").or(EmployeeSpecs.named("Bob")),
List.of("Alice", "Alice", "Bob"));
}
@Test
void nullRequiresAnExplicitPredicate() {
equivalent(employee.name.isNull(), EmployeeSpecs.withoutName(),
Arrays.asList((String) null));
}
@Test
void stringAttributeTypoFailsAtRuntime() {
Specification<Employee> typo = (root, query, builder) ->
builder.equal(root.get("missingName"), "Alice");
assertThatThrownBy(() -> employees.findAll(typo))
.isInstanceOf(InvalidDataAccessApiUsageException.class);
}
@Test
void equalityHelpersRejectNull() {
assertThatThrownBy(() -> employee.name.eq((String) null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> EmployeeSpecs.named(null))
.isInstanceOf(IllegalArgumentException.class);
}
}
Выполните из корня проекта с выбранным JDK17:
gradle --version
gradle --no-daemon clean test
Теперь проект содержит десять тестов: два исходных теста Querydsl и восемь проверок сравнения и контрактов. В шести сравнениях результатов обе реализации должны совпасть с независимо заданным списком; одного совпадения друг с другом недостаточно. Повторите clean test, чтобы также проверить повторную генерацию Q-типов.
| Случай | Ожидаемый результат |
|---|---|
| Имя равно Alice | Alice дважды; дубликаты сохраняются |
| Отсутствующее имя | Нет строк |
Имя равно A_% |
Это буквальное значение, а не шаблон LIKE |
| Alice AND Bob | Нет строк |
| Alice OR Bob | Alice, Alice, Bob в порядке имён |
| Явный предикат null | Одно имя null |
Сохраните различающиеся контракты
Сгенерированный Querydsl employee.name — член Java. В этом примере Specification выражение root.get("name") использует строковый атрибут, поэтому намеренная опечатка missingName компилируется, но вызывает ошибку при выполнении репозиторием. Это не означает, что Specifications обязаны использовать строки: другой вариант — статическая метамодель JPA.
Вспомогательный метод named в примере явно отклоняет null; вызов равенства Querydsl тоже его отклоняет. Для null используйте отдельные предикаты withoutName()/isNull(). Это политика входных данных примера; при реальной миграции не превращайте null в отсутствующий фильтр незаметно.
| Инструмент | Что оценивать |
|---|---|
| Querydsl JPA | Сгенерированные Q-типы, формы запросов JPA и совместимость процессора |
| JPA Specification | Повторно используемые фильтры Criteria и интеграция репозитория |
| jOOQ | SQL-ориентированный DSL и моделирование схемы; генерация необязательна |
| Jdbi | Запросы JDBC и отображение результатов |
| Doma | Обработка при компиляции, шаблоны SQL и Criteria API |
Эти инструменты работают на разных уровнях. Тест не сравнивает jOOQ, Jdbi и Doma, не измеряет производительность и не устанавливает эквивалентность производственной БД. До смены инструмента добавьте реальные соединения, сортировку, пагинацию, политику null и транзакционные случаи; проверьте выбранную БД и условия лицензий нужных артефактов.
Руководство по изучению Spring Boot связывает настройку проекта, проверки хранения, HTTP-тестирование и упаковку. Каждая статья фиксирует собственные версии воспроизведения.
Обсуждение