This Java library directly converts HTTP query parameters into Hibernate predicates, making it easier to implement dynamic filters for APIs.
-
Java 21 or later.
-
Spring boot 3.X for application configuration.
-
spring-boot-starter-data-jpa for entity management and filtering predicates.
-
Consuming projects must compile with the
-parametersjavac flag if they use the constructor-based auto-projection methods (findEntities,findDistinctEntities,findPageEntities,findDistinctPageEntities— see Constructor-Based Auto Projections). Without it, these methods throwIllegalStateException: Parameter names not available.Maven (
pom.xml):
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>Gradle (build.gradle):
tasks.withType(JavaCompile) {
options.compilerArgs << '-parameters'
}This flag only needs to be applied to the module(s) declaring your projection DTOs —
it must recompile the DTO for the change to take effect (a clean build is required
after enabling it for the first time).
Maven
<dependency>
<groupId>io.github.zorin95670</groupId>
<artifactId>spring-query-filter</artifactId>
<version>4.2.1</version>
</dependency>Gradle
dependencies {
implementation("io.github.zorin95670:spring-query-filter:4.2.1")
}The library uses Spring's default pagination mechanism to manage paginated responses.
You can control pagination through the following query parameters:
page: Specifies the zero-based page index to retrieve. Defaults to0if not provided.size: Specifies the number of elements per page. Must be between1and100, with a default of10.sort: Specifies the field(s) by which to sort the results.direction: Specifies the sorting direction. Acceptable values areasc(ascending) ordesc(descending). Defaults todesc.
By default, filtering dates will use a timestamp. However, you can specify a custom date format by including the dateFormat parameter in your request.
Example Request:
GET http://localhost:8080/myEndpoint?dateFormat=yyyyMMdd&date=20241201Notes:
- The
dateFormatparameter defines the expected format of thedatevalue in the request. - For valid date format patterns, refer to the Java DateFormat documentation.
Examples of Supported Formats:
yyyyMMdd→20241201MM/dd/yyyy→12/01/2024dd-MM-yyyy→01-12-2024
Make sure the date value matches the specified dateFormat to avoid parsing errors.
Single Sort
To fetch the second page with 7 elements per page, sorted by name in ascending order:
GET http://localhost:8080/myEndpoint?page=1&size=7&sort=name,asc
Multiple Sort Criteria
To sort by multiple fields, chain sort parameters. For example, to sort by name in ascending order and then by price in descending order:
GET http://localhost:8080/myEndpoint?sort=name,asc&sort=price,desc
Here is the list of operator that can be used to filter data:
eq_or: equalsgt_: greater thanlt_: lesser than_bt_: betweenlk_: like, with*or%as a wildcard equivalent to SQL%not_: negation|: or
| Type | eq_ |
gt_ |
lt_ |
_bt_ |
lk_ |
|---|---|---|---|---|---|
| Boolean | ✅ | ❌ | ❌ | ❌ | ❌ |
| UUID | ✅ | ❌ | ❌ | ❌ | ❌ |
| String | ✅ | ❌ | ❌ | ❌ | ✅ |
| Integer | ✅ | ✅ | ✅ | ✅ | ❌ |
| Long | ✅ | ✅ | ✅ | ✅ | ❌ |
| Float | ✅ | ✅ | ✅ | ✅ | ❌ |
| Double | ✅ | ✅ | ✅ | ✅ | ❌ |
| Date | ✅ | ✅ | ✅ | ✅ | ❌ |
Example:
http://localhost:8080/myEndpoint?name=toto&age=gt_10&age=lt_20&updateDate=1_bt_5
This query filters YourEntity where:
nameis equal tototo.ageis greater than 10 and less than 20.updateDateis between 1 and 5 (timestamps assumed, simplified here as integers).
Adding multiple query parameters combines filters with an SQL AND.
For a single field, you can specify an OR filter like this: ?name=toto|tata, meaning name should be either toto or tata.
To mix AND and OR:
?name=tata&name=toto|tutu
This corresponds to the SQL:
SELECT * FROM you_entity_table WHERE name = 'tata' AND (name = 'toto' OR name = 'tutu');To negate a filter, use the not_ prefix. For OR filters, apply not_ to each value.
?name=not_test: name is nottest?name=not_lk_test*: name does not matchtest*?name=not_toto&name=not_tata: name is neithertotonortata
This library provides default filters for:
StringDateIntegerLongFloatDoubleBooleanUUID
To enable filtering on a field, annotate it with @FilterType.
Example Entity:
import io.github.zorin95670.predicate.FilterType;
(...)
@Entity
@Table(name = "your_entity_table")
public class YourEntity {
@Id
@Column(name = "id")
@FilterType(type = Long.class)
private Long id;
(...)
}import io.github.zorin95670.query.SpringQueryFilter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
public class ExampleController {
@GetMapping("/myEndpoint")
public Page<MyEntity> find(@RequestParam Map<String, List<String>> allParams,
@PageableDefault(page = 0, size = 10, sort = "lastName", direction = Sort.Direction.ASC) Pageable pageable) {
return myService.find(allParams, queryFilter);
}
}public interface YourEntityRepository extends JpaRepository<YourEntity, Long> {
Page<YourEntity> find(Specification<YourEntity> specification, Pageable pageable);
}Service Interface:
public interface YourEntityService {
(...)
Page<YourEntity> find(Map<String, List<String>> filters, Pageable pageable);
}Service Implementation:
@Service
@Transactional
public class YourEntityServiceImpl implements YourEntityService {
(...)
@Override
public Page<YourEntity> find(final Map<String, List<String>> filters,
final Pageable pageable) {
return this.yourEntityRepository.find(
new QueryFilterSpecification<>(YourEntity.class, filters),
pageable
);
}
}To apply filters without using query parameters directly from the controller, you can manually define filter conditions:
import java.util.HashMap;
import java.util.List;
@Service
@Transactional
public class YourEntityServiceImpl implements YourEntityService {
(...)
@Override
public Page<YourEntity> specificFind() {
Map<String, List<String>> filters = new HashMap<>();
// Filter by id is not equal to 1
filters.put("id", List.of("not_1"));
// Filter by name that begin by "test"
filters.put("name", List.of("lk_test%"));
return this.yourEntityRepository.find(
new QueryFilterSpecification<>(YourEntity.class, filters),
PageRequest.of(0, 10, Sort.by(Sort.Order.asc("name")))
);
}
}If you want to automatically generate DTOs from your JPA entities, you can use the library spring-query-swagger-processor.
This allows you to:
- Avoid manually creating DTOs for filtering.
- Directly map generated DTOs into the
SpringQueryFilterSpecification.
Example:
import io.github.zorin95670.specification.SpringQueryFilterSpecification;
// Suppose MyEntityDto is generated from MyEntity using spring-query-swagger-processor
MyEntityDto dto1 = new MyEntityDto();
MyEntityDto dto2 = new MyEntityDto();
// Use the constructor that accepts DTOs
SpringQueryFilterSpecification<MyEntity> spec =
new SpringQueryFilterSpecification<>(MyEntity.class, dto1, dto2);Notes:
- The constructor
SpringQueryFilterSpecification(Class<T> entityClass, Object... dtos)will extract allList<String>fields from the DTOs and populate the internal filters map automatically. - This ensures that DTOs generated from your entities are directly usable for filtering without extra manual mapping.
If you need support for a custom type, you can extend ComparablePredicateFilter.
Example:
public class YourTypePredicateFilter<T> extends ComparablePredicateFilter<T, YourType> {
public YourTypePredicateFilter(String name, String value) {
super(name, value);
}
@Override
public YourType parseValue(String value) {
// You have to parse the string value to YourType
return YourType.parseYourType(value);
}
}For non-comparable types, extend PredicateFilter directly.
Example:
public abstract class YourTypePredicateFilter<T> extends PredicateFilter<T, YourType> {
YourTypePredicateFilter(String name, String value) {
super(name, value);
}
@Override
public YourType parseValue(String value) {
// You have to parse the string value to YourType
return YourType.parseYourType(value);
}
@Override
public Predicate getPredicate(final int index, final CriteriaBuilder builder, final Expression<YourType> field) {
// Example of content
Predicate predicate;
if (PredicateOperator.INFERIOR.equals(this.getOperator(index))) {
predicate = builder.lessThan(field, parseValue(this.getValue(index)));
} else if (PredicateOperator.SUPERIOR.equals(this.getOperator(index))) {
predicate = builder.greaterThan(field, parseValue(this.getValue(index)));
} else {
predicate = builder.equal(field, parseValue(this.getValue(index)));
}
if (this.getIsNotOperator(index)) {
return builder.not(predicate);
}
return predicate;
}
}Create a class that extends QueryFilterSpecification and override getPredicateFilter:
public class CustomQueryFilterSpecification<T> extends QueryFilterSpecification<T> {
public CustomQueryFilterSpecification(Class<T> entityClass, Map<String, List<String>> filters) {
super(entityClass, filters);
}
@Override
public IPredicateFilter<T, ?> getPredicateFilter(final Class<?> type, final String name, final String value) {
if (Yourtype.class.equals(type)) {
return new YourTypePredicateFilter<>(name, value);
}
// To manage default type
return super.getPredicateFilter(type, name, value);
}
}You can specify a custom field name for the date format by overriding the behavior in your CustomQueryFilterSpecification.
Here's an example implementation:
public class CustomQueryFilterSpecification<T> extends QueryFilterSpecification<T> {
public CustomQueryFilterSpecification(Class<T> entityClass, Map<String, List<String>> filters) {
super(entityClass, filters);
this.setDateFormatFieldName("dateFormat");
}
@Override
public IPredicateFilter<T, ?> getPredicateFilter(final Class<?> type, final String name, final String value) {
if (Yourtype.class.equals(type)) {
return new YourTypePredicateFilter<>(name, value);
}
// To manage default type
return super.getPredicateFilter(type, name, value);
}
}The library provides a generic SpringQueryExecutor allowing you to execute JPA Criteria queries from a Specification while supporting:
- Dynamic field projections
SELECT DISTINCT- Sorting
- Pagination
- DTO constructor projections
@Service
@Transactional
public class YourEntityServiceImpl implements YourEntityService {
private final SpringQueryExecutor queryExecutor;
public YourEntityServiceImpl(SpringQueryExecutor queryExecutor) {
this.queryExecutor = queryExecutor;
}
}Retrieve only one attribute instead of the entire entity.
List<String> names = queryExecutor.find(
YourEntity.class,
String.class,
new SpringQueryFilterSpecification<>(YourEntity.class, filters),
"name"
);Generated SQL:
SELECT name
FROM your_entity_table
WHERE ...Retrieve unique values for a field.
List<String> names = queryExecutor.findDistinct(
YourEntity.class,
String.class,
new SpringQueryFilterSpecification<>(YourEntity.class, filters),
"name"
);Generated SQL:
SELECT DISTINCT name
FROM your_entity_table
WHERE ...When multiple fields are specified, the executor uses a JPA constructor expression.
DTO:
public record UserSummary(
String firstName,
String lastName
) {}Query:
List<UserSummary> users = queryExecutor.find(
UserEntity.class,
UserSummary.class,
new SpringQueryFilterSpecification<>(UserEntity.class, filters),
"firstName",
"lastName"
);Generated SQL:
SELECT first_name, last_name
FROM user_entity
WHERE ...When projecting multiple fields:
- the target class must expose a public constructor
- constructor parameter order must match the order of
fieldNames - constructor parameter types must match the selected field types
For Java Records this works automatically.
Tip: manually keeping
fieldNamesin sync with the constructor order is error-prone. See Constructor-Based Auto Projections for a way to derivefieldNamesautomatically from the target class's constructor instead.
For DTO projections, manually listing fieldNames in the exact constructor order is
error-prone — especially when the DTO's field order doesn't match the entity's. The
*Entities-suffixed methods (findEntities, findDistinctEntities, findPageEntities,
findDistinctPageEntities) remove this burden entirely: instead of taking fieldNames,
they derive the projected field list directly from the resultType's constructor via
reflection.
public record UserSummary(
String firstName,
String lastName
) {}
List<UserSummary> users = queryExecutor.findEntities(
UserEntity.class,
UserSummary.class,
new SpringQueryFilterSpecification<>(UserEntity.class, filters)
);This is equivalent to calling find(UserEntity.class, UserSummary.class, specification, "firstName", "lastName"), but the field list is read from UserSummary's constructor
instead of being typed out by hand — so it can never drift out of sync with the DTO.
Sorted, paginated, and distinct variants follow the same naming convention as their
fieldNames-based counterparts:
// Sorted
List<UserSummary> users = queryExecutor.findEntities(
UserEntity.class, UserSummary.class, specification, Sort.by("lastName"));
// Distinct
List<UserSummary> users = queryExecutor.findDistinctEntities(
UserEntity.class, UserSummary.class, specification);
// Paginated
Page<UserSummary> page = queryExecutor.findPageEntities(
UserEntity.class, UserSummary.class, specification, PageRequest.of(0, 20));
// Distinct + paginated
Page<UserSummary> page = queryExecutor.findDistinctPageEntities(
UserEntity.class, UserSummary.class, specification, PageRequest.of(0, 20));resultTypemust declare exactly one constructor with parameters having the highest parameter count among its declared constructors. If several Lombok-generated constructors coexist (e.g.@AllArgsConstructoralongside@SuperBuilder's internal builder-accepting constructor), the one with the most parameters is selected automatically — so@SuperBuildercan be used freely alongside@AllArgsConstructorwithout conflict.- Parameter names must be available at runtime, which requires compiling
resultTypewith the-parametersjavac flag (see Requirements). - Avoid non-
staticlocal or inner classes asresultType(e.g. a class declared inside a method or as a non-static nested class): the compiler silently adds a synthetic constructor parameter capturing the enclosing instance, which breaks field resolution. Top-level classes,staticnested classes, and Java records are all safe.
When using a findDistinct*/*DistinctPage* variant together with a Sort (explicit or
via Pageable), every sorted property must also be part of the projection. This is a
PostgreSQL (and most SQL databases) requirement: ORDER BY expressions must appear in the
SELECT DISTINCT list, since sorting by a column excluded from the projection is ambiguous
once rows are deduplicated. Sorting by a non-projected field will fail at the database level
(e.g. PSQLException: for SELECT DISTINCT, ORDER BY expressions must appear in select list).
List<String> names = queryExecutor.find(
UserEntity.class,
String.class,
new SpringQueryFilterSpecification<>(UserEntity.class, filters),
Sort.by(Sort.Order.asc("name")),
"name"
);Generated SQL:
SELECT name
FROM user_entity
WHERE ...
ORDER BY name ASCPage<UserSummary> page = queryExecutor.findPage(
UserEntity.class,
UserSummary.class,
new SpringQueryFilterSpecification<>(UserEntity.class, filters),
PageRequest.of(
0,
20,
Sort.by("lastName")
),
"firstName",
"lastName"
);Page<String> page = queryExecutor.findDistinctPage(
UserEntity.class,
String.class,
new SpringQueryFilterSpecification<>(UserEntity.class, filters),
PageRequest.of(0, 20),
"name"
);Generated SQL:
SELECT DISTINCT name
FROM user_entity
WHERE ...The total count is computed using a dedicated COUNT(DISTINCT ...) query.
If no field names are provided, the executor returns complete entities.
List<UserEntity> users = queryExecutor.find(
UserEntity.class,
UserEntity.class,
new SpringQueryFilterSpecification<>(UserEntity.class, filters)
);Equivalent SQL:
SELECT *
FROM user_entity
WHERE ...The executor is designed to work naturally with SpringQueryFilterSpecification.
Map<String, List<String>> filters = Map.of(
"name", List.of("lk_test%"),
"id", List.of("gt_10")
);
Specification<UserEntity> specification =
new SpringQueryFilterSpecification<>(
UserEntity.class,
filters
);
List<String> names = queryExecutor.findDistinct(
UserEntity.class,
String.class,
specification,
"name"
);This produces a dynamic query equivalent to:
SELECT DISTINCT name
FROM user_entity
WHERE name LIKE 'test%'
AND id > 10