Querydsl and JPA Specifications: Test a Query Before Switching
Choose a query tool by the work you need to express and verify. A release gap can prompt investigation, but it does not establish that a replacement preserves an application’s query behavior.
The original 2023 concern predates the later Querydsl 5.1.0 release. A release tag proves that release exists; assess the repository, artifact coordinates and compatibility of the line you intend to adopt separately. The example below pins one reproduction environment rather than recommending a current production version.
Compare a representative predicate
Seed the same five rows, build both predicates and check explicit expected results. The tests cover this entity on H2. A migration decision also needs the joins, projections, pagination and database behavior your application actually uses.
Spring Data JPA Specifications combine Criteria predicates and run them through JpaSpecificationExecutor. For simple repository filters, that can be sufficient without replacing the JPA persistence model.
Add three files to the Querydsl example
Start with all five files from the complete Jakarta Querydsl project, including Employee.getName(). Its reproduction uses Java17, Gradle8.4, Boot3.5.7, Querydsl5.1.0 Jakarta and H2. Add the following three complete files; do not mix this configuration with the separate Boot4 examples in the guide.
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);
}
}
Run from the project root with JDK17 selected:
gradle --version
gradle --no-daemon clean test
The project now has ten tests: the original two Querydsl checks and eight comparison/contract checks. For the six result comparisons, both implementations must match an independently specified list; agreeing with each other alone is insufficient. Repeat clean test to check that Q types regenerate too.
| Case | Expected result |
|---|---|
| Name equals Alice | Alice twice; duplicates remain |
| Missing name | No rows |
Name equals A_% |
That literal value, not a LIKE pattern |
| Alice AND Bob | No rows |
| Alice OR Bob | Alice, Alice, Bob in name order |
| Explicit null predicate | One null name |
Preserve the contracts that differ
Querydsl’s generated employee.name is a Java member. In this Specification example, root.get("name") uses a string attribute, so the deliberate missingName typo compiles and fails when the repository executes it. This does not mean Specifications must use strings: the JPA static metamodel is another option.
The example’s named helper rejects null explicitly, and Querydsl’s equality call rejects it too. Use the separate withoutName()/isNull() predicates for null values. That is the example’s input policy; do not silently change null into an absent filter during a real migration.
| Tool | What to evaluate |
|---|---|
| Querydsl JPA | Generated Q types, JPA query shapes and processor compatibility |
| JPA Specification | Reusable Criteria filters and repository integration |
| jOOQ | SQL-centric DSL and schema modelling; generation is optional |
| Jdbi | JDBC queries and result mapping |
| Doma | Compile-time processing, SQL templates and Criteria APIs |
These tools occupy different layers. The test does not compare jOOQ, Jdbi or Doma, benchmark performance, or establish production-database equivalence. Before changing tools, add your actual joins, ordering, pagination, null policy and transaction cases; check the selected database and license terms for the chosen artifacts.
The Spring Boot learning guide connects project setup, persistence checks, HTTP testing and packaging. Each article records its own reproduction versions.
Discussion