Dynamic query filtering for Spring applications. Pass filter expressions as URL parameters and apply them to JPA repositories, MongoDB collections, or in-memory Java objects.
The library parses filter expressions into abstract syntax trees, then converts them to JPA Criteria queries, MongoDB queries, or Java Predicates depending on your module. You can also use the filter builder to construct queries programmatically.
⚠️ About Release 3.0.0
Spring Filter 3.0.0 is a new release built from the ground up. It includes much better integration with Spring, with many new features, enhancements and bug fixes. The language syntax didn't change, frontend applications will therefore not require any modification. The new
FilterBuilderclass is incompatible with the previous one and other breaking changes are present but the basic usage of the library remains similar. Please feel free to create an issue if you notice anything wrong. Consider supporting the project by sponsoring us.
You can access the older version in the 2.x.x branch.
Example (try it live)
/search?filter= average(ratings) > 4.5 and brand.name in ['audi', 'land rover'] and (year > 2018 or km < 50000) and color : 'white' and accidents is empty
@Entity public class Car {
@Id long id;
int year;
int km;
@Enumerated Color color;
@ManyToOne Brand brand;
@OneToMany List<Accident> accidents;
@ElementCollection List<Integer> ratings;
}The library handles booleans, dates, enums, functions, and entity relations. JPA module generates criteria queries, MongoDB module generates aggregation pipelines, and predicate module filters in-memory objects.
Sponsor our project and have your issues prioritized.
Filter JPA entities directly in database queries. The module converts filter expressions to JPA Criteria API specifications.
<dependency>
<groupId>com.turkraft.springfilter</groupId>
<artifactId>jpa</artifactId>
<version>3.2.4</version>
</dependency>@GetMapping("/cars")
Page<Car> search(@Filter Specification<Car> spec, Pageable page) {
return repository.findAll(spec, page);
}The repository must implement JpaSpecificationExecutor. SimpleJpaRepository is a standard implementation. Remove the Pageable argument if you don't need pagination.
@GetMapping("/cars/native")
List<Car> searchNative(@Filter Specification<Car> spec) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Car> query = cb.createQuery(Car.class);
Root<Car> root = query.from(Car.class);
query.where(spec.toPredicate(root, query, cb));
return entityManager.createQuery(query).getResultList();
}@GetMapping("/cars/summary")
List<CarSummary> searchProjection(@Filter Specification<Car> spec) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<CarSummary> query = cb.createQuery(CarSummary.class);
Root<Car> root = query.from(Car.class);
query.select(cb.construct(CarSummary.class,
root.get("brand").get("name"),
cb.count(root)));
query.where(spec.toPredicate(root, query, cb));
query.groupBy(root.get("brand").get("name"));
return entityManager.createQuery(query).getResultList();
}Filter MongoDB documents using Spring Data MongoDB queries.
<dependency>
<groupId>com.turkraft.springfilter</groupId>
<artifactId>mongo</artifactId>
<version>3.2.4</version>
</dependency>@GetMapping("/cars")
Page<Car> search(@Filter(entityClass = Car.class) Query query, Pageable page) {
return mongoTemplate.find(query.with(page), Car.class);
}public interface CarRepository extends MongoRepository<Car, String> {
@Query("?0")
List<Car> findAll(Document document);
@Query("?0")
Page<Car> findAll(Document document, Pageable pageable);
}
@GetMapping("/cars")
Page<Car> search(@Filter(entityClass = Car.class) Document document, Pageable page) {
return repository.findAll(document, page);
}Filter in-memory collections using Java Predicates. Works with any POJO, no database required.
<dependency>
<groupId>com.turkraft.springfilter</groupId>
<artifactId>predicate</artifactId>
<version>3.2.4</version>
</dependency>@GetMapping("/cars")
List<Car> search(@Filter Predicate<Car> predicate) {
List<Car> allCars = loadCarsFromCache();
return allCars.stream()
.filter(predicate)
.collect(Collectors.toList());
}@Autowired FilterPredicateConverter converter;
public List<Car> filterCars(List<Car> cars, String filterExpression) {
FilterPredicate<Car> predicate = converter.convert(filterExpression, Car.class);
return cars.stream()
.filter(predicate)
.collect(Collectors.toList());
}The predicate module is useful when:
- Filtering cached data in memory
- Filtering API responses before returning to client
- Testing filter logic without database
- Filtering configuration objects or enums
- Processing batch data in memory
@GetMapping("/cars/cached")
List<Car> searchCached(@Filter Predicate<Car> predicate) {
return cacheService.getAllCars().stream()
.filter(predicate)
.collect(Collectors.toList());
}
@GetMapping("/cars/filter-after-fetch")
List<Car> filterAfterFetch(@Filter Predicate<Car> predicate) {
List<Car> cars = externalApiClient.fetchAllCars();
return cars.stream()
.filter(predicate)
.collect(Collectors.toList());
}// Filter by collection size
GET /cars?filter=size(accidents) > 2
GET /owners?filter=size(cars) : 0The predicate module supports all standard operators and the size() function for collections, arrays, maps, and strings.
Build filter expressions programmatically instead of writing filter strings manually.
<dependency>
<groupId>com.turkraft.springfilter</groupId>
<artifactId>core</artifactId>
<version>3.2.4</version>
</dependency>@Autowired FilterBuilder fb;
FilterNode filter = fb.field("year").equal(fb.input(2025))
.and(fb.field("category").isNull())
.get();
@Autowired ConversionService cs;
String query = cs.convert(filter, String.class);
// year : 2025 and category is nullFilterNode filter = fb.field("brand.name").in(
fb.collection(fb.input("audi"), fb.input("bmw"))
).and(
fb.field("year").greaterThan(fb.input(2020))
.or(fb.field("km").lessThan(fb.input(50000)))
).get();@Autowired SizeFunction sizeFunction;
FilterNode filter = fb.function(sizeFunction, fb.field("accidents"))
.greaterThan(fb.input(2))
.and(fb.field("year").lessThan(fb.input(2015)))
.get();Add automatic Swagger documentation for endpoints with @Filter parameters.
<dependency>
<groupId>com.turkraft.springfilter</groupId>
<artifactId>openapi</artifactId>
<version>3.2.4</version>
</dependency>Just add the dependency. Swagger UI automatically shows:
- All filterable fields with types
- Nested relations
- Enum values
- Example queries
- Operator reference
- Available functions
Works with JPA, MongoDB, and Predicate modules.
The page-sort module provides annotations for pagination, sorting, and field selection.
<dependency>
<groupId>com.turkraft.springfilter</groupId>
<artifactId>page-sort</artifactId>
<version>3.2.4</version>
</dependency>@GetMapping("/cars")
Page<Car> search(@Filter Specification<Car> spec, @Page Pageable page) {
return repository.findAll(spec, page);
}Usage: ?page=0&size=20&sort=-year (prefix - for descending)
@GetMapping("/cars")
Page<Car> search(
@Page(pageParameter = "p", sizeParameter = "limit", sortParameter = "order") Pageable page) {
return repository.findAll(page);
}Now use ?p=0&limit=50&order=-year
@GetMapping("/cars")
List<Car> search(@Sort org.springframework.data.domain.Sort sort) {
return repository.findAll(sort);
}Use ?sort=-year or ?sort=-year,name
@Fields
@GetMapping("/cars")
List<Car> search() {
return repository.findAll();
}Use ?fields=id,brand.name,year to return only specified fields. Uses Jackson's filtering internally.
// Include specific fields
?fields= id,name,email
// Exclude fields
?fields= *,-password,-ssn
// Nested fields
?fields= id,brand.name,brand.country
// Wildcards
?fields= user.*@Fields
@GetMapping("/cars")
Page<Car> search(
@Filter Specification<Car> spec,
@Page Pageable page) {
return repository.findAll(spec, page);
}Use all features together:
/cars?filter=year>2020&page=0&size=20&sort=-year&fields=id,brand.name,year
The openapi module automatically generates documentation for these parameters when both dependencies are present.
Use spring-filter-query-builder to build queries in JavaScript/TypeScript.
import { sfAnd, sfEqual, sfGt, sfIsNull, sfLike, sfNot, sfOr } from 'spring-filter-query-builder';
const filter = sfAnd([
sfAnd([sfEqual('status', 'active'), sfGt('createdAt', '1-1-2000')]),
sfOr([sfLike('value', '*hello*'), sfLike('name', '*world*')]),
sfNot(sfOr([sfGt('id', 100), sfIsNull('category.order')])),
]);
fetch('http://api/person?filter=' + filter.toString());See spring-filter-ng documentation.
field
field.nested
field.nested.deep
123 // integer
-321.123 // decimal
true, false // boolean
'text' // string
'1-01-2023' // date (format depends on Spring ConversionService)
'escape \' quote' // escaped string
[1, 2, 3]
['a', 'b', 'c']
[field, nested.field, 'literal']
[field, ['nested', 'array'], 99]
size(collection)
size(field.collection)
today()
`placeholder_name`
Placeholders are resolved by custom placeholder processors you implement.
a and b // logical and
a or b // logical or
not a // logical not
a : b // equals
a ! b // not equals
a > b // greater than
a >: b // greater than or equal
a < b // less than
a <: b // less than or equal
a ~ 'pattern' // like (% and _ wildcards)
a ~~ 'pattern' // case-insensitive like
a in [x, y] // in collection
a not in [x, y] // not in collection
a is null // null check
a is not null // not null check
a is empty // empty check (collections/strings)
a is not empty // not empty check
Use parentheses to control evaluation order:
a and (b or c)
(status : 'active' or status : 'pending') and year > 2020
// Simple equality
?filter= status : 'active'
// Multiple conditions
?filter= year > 2020 and km < 50000
// OR conditions
?filter= color : 'red' or color : 'blue'
// Filter by related entity field
?filter= brand.name : 'audi'
// Nested relations
?filter= brand.manufacturer.country : 'germany'
// Check if relation exists
?filter= brand is not null
// Multiple relation conditions
?filter= brand.name : 'audi' and dealer.city : 'berlin'
// Check if value is in list
?filter= status in ['active', 'pending', 'review']
// Check collection size
?filter= size(accidents) > 2
// Check if collection is empty
?filter= accidents is empty
// Check if collection is not empty
?filter= ratings is not empty
// Like with wildcards (% = any chars, _ = single char)
?filter= name ~ '%john%'
// Case-insensitive like
?filter= name ~~ 'JOHN'
// Starts with
?filter= email ~ 'admin%'
// Ends with
?filter= filename ~ '%.pdf'
// Pattern matching
?filter= code ~ 'PRD-____-2023'
// Date comparison (format depends on your Spring configuration)
?filter= createdAt > '2023-01-01'
// Date range
?filter= createdAt > '2023-01-01' and createdAt < '2023-12-31'
// Relative dates with today() function
?filter= createdAt > today()
// Nested conditions with precedence
?filter= (year > 2020 and km < 30000) or (year > 2018 and km < 10000)
// Mix of different operators
?filter= brand.name in ['audi', 'bmw'] and year > 2020 and accidents is empty and color ! 'white'
// Collection size with relations
?filter= size(owner.vehicles) > 3 and status : 'active'
// Check for null
?filter= deletedAt is null
// Check for not null
?filter= description is not null
// Empty collection
?filter= tags is empty
// Non-empty collection
?filter= children is not empty
All modules handle:
- Primitives (int, long, double, boolean, etc.)
- Strings
- Enums
- Dates (LocalDate, LocalDateTime, Date, Instant, etc.)
- Collections (List, Set)
- Arrays
- Entity relations (@ManyToOne, @OneToMany, @ManyToMany)
Date parsing uses Spring's ConversionService. Configure it to change date formats.
Define custom operators by extending FilterInfixOperator, FilterPrefixOperator, or FilterPostfixOperator:
@Component
public class ContainsOperator extends FilterInfixOperator {
public ContainsOperator() {
super("contains", 5);
}
}Then implement processors for each module you use:
@Component
public class ContainsOperationExpressionProcessor implements
FilterInfixOperationProcessor<FilterExpressionTransformer, Expression<?>> {
@Override
public Class<FilterExpressionTransformer> getTransformerType() {
return FilterExpressionTransformer.class;
}
@Override
public Class<ContainsOperator> getDefinitionType() {
return ContainsOperator.class;
}
@Override
public Expression<?> process(FilterExpressionTransformer transformer,
InfixOperationNode source) {
// Implementation
}
}Register the operator with autoconfiguration or manual configuration.
Define custom functions by extending FilterFunction:
@Component
public class LowerFunction extends FilterFunction {
public LowerFunction() {
super("lower");
}
}Implement processors for each module:
@Component
public class LowerFunctionExpressionProcessor implements
FilterFunctionProcessor<FilterExpressionTransformer, Expression<?>> {
@Override
public Class<FilterExpressionTransformer> getTransformerType() {
return FilterExpressionTransformer.class;
}
@Override
public Class<LowerFunction> getDefinitionType() {
return LowerFunction.class;
}
@Override
public Expression<?> process(FilterExpressionTransformer transformer,
FunctionNode source) {
Expression<?> arg = transformer.transform(source.getArgument(0));
return transformer.getCriteriaBuilder().lower((Expression<String>) arg);
}
}By default, filters are read from the filter query parameter. Override this:
@GetMapping("/cars")
List<Car> search(@Filter(parameter = "q") Specification<Car> spec) {
return repository.findAll(spec);
}Now use ?q=year > 2020 instead of ?filter=year > 2020.
MongoDB requires explicit entity class specification:
@GetMapping("/cars")
List<Car> search(@Filter(entityClass = Car.class) Query query) {
return mongoTemplate.find(query, Car.class);
}Use Optional to handle missing filter parameters:
@GetMapping("/cars")
List<Car> search(@Filter Optional<Specification<Car>> spec) {
return repository.findAll(spec.orElse(null));
}Apply filters without Spring MVC annotations:
@Autowired FilterSpecificationConverter jpaConverter;
@Autowired FilterQueryConverter mongoConverter;
@Autowired FilterPredicateConverter predicateConverter;
public void manualFiltering() {
// JPA
Specification<Car> spec = jpaConverter.convert("year > 2020", Car.class);
List<Car> jpaCars = repository.findAll(spec);
// MongoDB
Query query = mongoConverter.convert("year > 2020", Car.class);
List<Car> mongoCars = mongoTemplate.find(query, Car.class);
// Predicate
FilterPredicate<Car> predicate = predicateConverter.convert("year > 2020", Car.class);
List<Car> filteredCars = allCars.stream().filter(predicate).collect(Collectors.toList());
}Spring Filter uses Spring's ConversionService for type conversions. Configure it to customize date formats, enum handling, etc:
@Configuration
public class ConversionConfig {
@Bean
public ConversionService conversionService() {
DefaultConversionService service = new DefaultConversionService();
service.addConverter(new StringToLocalDateConverter());
return service;
}
}ParseContext allows you to intercept and modify filter expressions during parsing. It provides two hooks: field mapping and node mapping.
Map API field names to database field names:
@Service
public class ProductService {
@Autowired FilterParser parser;
@Autowired FilterSpecificationConverter converter;
public List<Product> search(String filter) {
ParseContext ctx = new ParseContextImpl(field -> {
return switch (field) {
case "price" -> "unitPrice";
case "category" -> "productCategory.name";
case "inStock" -> "inventory.quantity";
default -> field;
};
}, null);
FilterNode node = parser.parse(filter, ctx);
Specification<Product> spec = converter.convert(node);
return repository.findAll(spec);
}
}Now queries like ?filter=price > 100 automatically become unitPrice > 100 at the database level.
Automatically inject tenant filters into all queries:
@Service
public class TenantAwareFilterService {
@Autowired FilterParser parser;
@Autowired FilterSpecificationConverter converter;
@Autowired FilterBuilder fb;
public <T> Specification<T> parse(String filter, Long tenantId) {
ParseContext ctx = new ParseContextImpl(null, userNode -> {
FilterNode tenantFilter = fb.field("tenantId").equal(fb.input(tenantId)).get();
return fb.and(tenantFilter, userNode).get();
});
FilterNode node = parser.parse(filter, ctx);
return converter.convert(node);
}
}@GetMapping("/products")
Page<Product> search(@Filter String filter, @AuthenticationPrincipal User user) {
Specification<Product> spec = tenantService.parse(filter, user.getTenantId());
return repository.findAll(spec, pageable);
}User queries status : 'active' but the actual query becomes tenantId : 123 and status : 'active'.
Inject row-level security filters based on user permissions:
@Service
public class SecureFilterService {
@Autowired FilterParser parser;
@Autowired FilterSpecificationConverter converter;
@Autowired FilterBuilder fb;
public Specification<Document> parseSecure(String userQuery, User user) {
ParseContext ctx = new ParseContextImpl(null, userNode -> {
if (user.hasRole("ADMIN")) {
return userNode;
}
FilterNode securityFilter = fb.field("ownerId").equal(fb.input(user.getId()))
.or(fb.field("department").equal(fb.input(user.getDepartment())))
.get();
return fb.and(securityFilter, userNode).get();
});
FilterNode node = parser.parse(userQuery, ctx);
return converter.convert(node);
}
}Regular users automatically get filtered to their own documents or department documents. Admins see everything.
Restrict which fields users can filter on:
public class FieldAccessControlContext implements ParseContext {
private final Set<String> allowedFields;
public FieldAccessControlContext(User user) {
this.allowedFields = user.hasRole("ADMIN")
? Set.of("id", "name", "email", "salary", "ssn", "department")
: Set.of("id", "name", "department");
}
@Override
public UnaryOperator<String> getFieldMapper() {
return field -> {
if (!allowedFields.contains(field)) {
throw new SecurityException("Access denied to field: " + field);
}
return field;
};
}
}@GetMapping("/employees")
List<Employee> search(@Filter String filter, @AuthenticationPrincipal User user) {
ParseContext ctx = new FieldAccessControlContext(user);
FilterNode node = parser.parse(filter, ctx);
Specification<Employee> spec = converter.convert(node);
return repository.findAll(spec);
}Non-admin users trying ?filter=salary > 50000 will get a SecurityException.
Automatically filter out soft-deleted records:
@Service
public class SoftDeleteFilterService {
@Autowired FilterParser parser;
@Autowired FilterSpecificationConverter converter;
@Autowired FilterBuilder fb;
public <T> Specification<T> parseWithSoftDelete(String userQuery) {
ParseContext ctx = new ParseContextImpl(null, userNode -> {
FilterNode notDeleted = fb.field("deletedAt").isNull().get();
return fb.and(notDeleted, userNode).get();
});
FilterNode node = parser.parse(userQuery, ctx);
return converter.convert(node);
}
}All queries automatically include deletedAt is null.
Log all filter queries with user context:
@Service
public class AuditingFilterService {
@Autowired FilterParser parser;
@Autowired FilterSpecificationConverter converter;
@Autowired AuditLogger auditLogger;
public <T> Specification<T> parseWithAudit(String query, User user) {
ParseContext ctx = new ParseContextImpl(null, node -> {
auditLogger.log("User {} executed filter: {}", user.getId(), query);
return node;
});
FilterNode node = parser.parse(query, ctx);
return converter.convert(node);
}
}Handle different naming conventions:
public class NormalizingParseContext implements ParseContext {
@Override
public UnaryOperator<String> getFieldMapper() {
return field -> {
String normalized = CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, field);
if (normalized.endsWith("_id")) {
return normalized.substring(0, normalized.length() - 3);
}
return normalized;
};
}
}ParseContext ctx = new NormalizingParseContext();
FilterNode node = parser.parse("userId : 123 and userName ~ 'john%'", ctx);Converts userId to user and userName to user_name automatically.
Chain multiple parse contexts for complex scenarios:
public class CompositeParseContext implements ParseContext {
private final List<ParseContext> contexts;
public CompositeParseContext(ParseContext... contexts) {
this.contexts = Arrays.asList(contexts);
}
@Override
public UnaryOperator<String> getFieldMapper() {
return field -> {
String result = field;
for (ParseContext ctx : contexts) {
result = ctx.getFieldMapper().apply(result);
}
return result;
};
}
@Override
public UnaryOperator<FilterNode> getNodeMapper() {
return node -> {
FilterNode result = node;
for (ParseContext ctx : contexts) {
result = ctx.getNodeMapper().apply(result);
}
return result;
};
}
}ParseContext ctx = new CompositeParseContext(
new FieldAccessControlContext(user),
new TenantFilterContext(user.getTenantId()),
new SoftDeleteContext()
);Apply multiple transformations in a single pass.
Use Spring's request scope for context-aware parsing:
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestParseContext implements ParseContext {
@Autowired HttpServletRequest request;
@Autowired UserService userService;
@Override
public UnaryOperator<FilterNode> getNodeMapper() {
return node -> {
User user = userService.getCurrentUser();
FilterNode tenantFilter = fb.field("tenantId")
.equal(fb.input(user.getTenantId())).get();
return fb.and(tenantFilter, node).get();
};
}
}@GetMapping("/products")
Page<Product> search(@Filter String filter) {
ParseContext ctx = applicationContext.getBean(RequestParseContext.class);
FilterNode node = parser.parse(filter, ctx);
Specification<Product> spec = converter.convert(node);
return repository.findAll(spec, pageable);
}Context automatically uses current request's user information.
@Test
void testFilterBuilder() {
FilterNode filter = fb.field("year").greaterThan(fb.input(2020)).get();
Specification<Car> spec = jpaConverter.convert(filter);
List<Car> result = repository.findAll(spec);
assertTrue(result.stream().allMatch(car -> car.getYear() > 2020));
}@SpringBootTest
@AutoConfigureMockMvc
class FilterIntegrationTest {
@Autowired MockMvc mockMvc;
@Test
void testFilter() throws Exception {
mockMvc.perform(get("/cars?filter=year > 2020"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[*].year").value(everyItem(greaterThan(2020))));
}
}@Test
void testPredicateFiltering() {
List<Car> cars = Arrays.asList(
new Car("Audi", 2021),
new Car("BMW", 2019),
new Car("Mercedes", 2022)
);
FilterPredicate<Car> predicate = predicateConverter.convert("year > 2020", Car.class);
List<Car> filtered = cars.stream().filter(predicate).collect(Collectors.toList());
assertEquals(2, filtered.size());
}JPA module generates optimized Criteria queries. The database executes filtering, so performance matches native SQL queries. Use appropriate indexes on filtered fields.
MongoDB module generates aggregation pipelines. Performance depends on indexes. Profile queries with MongoDB's explain plan.
Predicate module filters in memory. Performance is O(n) where n is collection size. Suitable for:
- Small collections
- Cached data
- Already loaded collections
- Testing
For large datasets, prefer JPA or MongoDB modules to filter at database level.
Pull requests welcome. Use Google Java Style for formatting.
- @marcopag90 and @glodepa - MongoDB support
- @sisimomo - JavaScript query builder
- @68ociredef - Angular query builder
MIT License - see LICENSE file.