Skip to content

Latest commit

 

History

72 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spring Query Filter

This Java library directly converts HTTP query parameters into Hibernate predicates, making it easier to implement dynamic filters for APIs.

Installation and Dependencies

Requirements

  • 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 -parameters javac flag if they use the constructor-based auto-projection methods (findEntities, findDistinctEntities, findPageEntities, findDistinctPageEntities — see Constructor-Based Auto Projections). Without it, these methods throw IllegalStateException: 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).

Adding the Dependency

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")
}

Usage in HTTP Requests

Pagination Parameters

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 to 0 if not provided.
  • size: Specifies the number of elements per page. Must be between 1 and 100, with a default of 10.
  • sort: Specifies the field(s) by which to sort the results.
  • direction: Specifies the sorting direction. Acceptable values are asc (ascending) or desc (descending). Defaults to desc.

Date Format

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=20241201

Notes:

  • The dateFormat parameter defines the expected format of the date value in the request.
  • For valid date format patterns, refer to the Java DateFormat documentation.

Examples of Supported Formats:

  • yyyyMMdd20241201
  • MM/dd/yyyy12/01/2024
  • dd-MM-yyyy01-12-2024

Make sure the date value matches the specified dateFormat to avoid parsing errors.

Examples

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

Filtering Operators

Here is the list of operator that can be used to filter data:

  • eq_ or : equals
  • gt_: greater than
  • lt_: lesser than
  • _bt_: between
  • lk_: 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

Basic Filtering Example

Example:

http://localhost:8080/myEndpoint?name=toto&age=gt_10&age=lt_20&updateDate=1_bt_5

This query filters YourEntity where:

  • name is equal to toto.
  • age is greater than 10 and less than 20.
  • updateDate is between 1 and 5 (timestamps assumed, simplified here as integers).

Adding multiple query parameters combines filters with an SQL AND.

Using OR

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');

Using NOT

To negate a filter, use the not_ prefix. For OR filters, apply not_ to each value.

  • ?name=not_test: name is not test
  • ?name=not_lk_test*: name does not match test*
  • ?name=not_toto&name=not_tata: name is neither toto nor tata

Usage in Code

Available Filters

This library provides default filters for:

  • String
  • Date
  • Integer
  • Long
  • Float
  • Double
  • Boolean
  • UUID

Declaring Filters in Entities

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;
    
    (...)
}

Retrieving Query Parameters in a Controller

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);
    }
}

Adding Methods in the Repository

public interface YourEntityRepository extends JpaRepository<YourEntity, Long> {

    Page<YourEntity> find(Specification<YourEntity> specification, Pageable pageable);
}

Using the Filter in a Service

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
        );
    }
}

Filter without queryParameters

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")))
        );
    }
}

Using DTOs Generated from Entities

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 all List<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.

Custom Types

If you need support for a custom type, you can extend ComparablePredicateFilter.

Creating a Custom PredicateFilter

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;
    }
}

Using Your Custom PredicateFilter

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);
    }
}

Dynamic Projections and DISTINCT Queries

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

Injecting the Executor

@Service
@Transactional
public class YourEntityServiceImpl implements YourEntityService {

    private final SpringQueryExecutor queryExecutor;

    public YourEntityServiceImpl(SpringQueryExecutor queryExecutor) {
        this.queryExecutor = queryExecutor;
    }
}

Selecting a Single Field

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 ...

Selecting Distinct Values

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 ...

Selecting Multiple Fields into a DTO

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 ...

Constructor Requirements

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 fieldNames in sync with the constructor order is error-prone. See Constructor-Based Auto Projections for a way to derive fieldNames automatically from the target class's constructor instead.

Constructor-Based Auto Projections

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));

Constructor Resolution Rules

  • resultType must declare exactly one constructor with parameters having the highest parameter count among its declared constructors. If several Lombok-generated constructors coexist (e.g. @AllArgsConstructor alongside @SuperBuilder's internal builder-accepting constructor), the one with the most parameters is selected automatically — so @SuperBuilder can be used freely alongside @AllArgsConstructor without conflict.
  • Parameter names must be available at runtime, which requires compiling resultType with the -parameters javac flag (see Requirements).
  • Avoid non-static local or inner classes as resultType (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, static nested classes, and Java records are all safe.

DISTINCT + Sorting Caveat

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).


Sorting Results

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 ASC

Paginated Projections

Page<UserSummary> page = queryExecutor.findPage(
    UserEntity.class,
    UserSummary.class,
    new SpringQueryFilterSpecification<>(UserEntity.class, filters),
    PageRequest.of(
        0,
        20,
        Sort.by("lastName")
    ),
    "firstName",
    "lastName"
);

Distinct Paginated Queries

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.


Returning Entities

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 ...

Combining with SpringQueryFilterSpecification

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

About

This Java library directly converts HTTP query parameters into Hibernate predicates, making it easier to implement dynamic filters for APIs.

Resources

Stars

8 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages