How to Build Advanced Search in Spring Boot with JPA and RSQL
Advanced search in Spring Boot often begins with a few query parameters and ends with an endpoint that is difficult to maintain. In this guide, we will build dynamic filters with Spring Data JPA and RSQL, add text search, pagination, and sorting, and validate every query before it runs.
RSQL solves one part of the problem. Instead of adding a parameter for every variation, the client can send an expression like this one.
status==PUBLISHED;price=between=(100,500)
The hard problem is not turning that text into a WHERE clause. It is deciding whether the client may query status, which operators it may apply, how much it may combine, and which constraints it should never control.
A direct integration between RSQL and JPA can accidentally turn the persistence model into a public contract. If we add costPrice, createdBy, or a relationship that exposes sensitive information tomorrow, those paths should not become available merely because they exist on an entity. We also do not want to discover in production that a caller can submit an IN with thousands of values, twenty joins, or a nearly unlimited page size.
I built and maintain rsql-jpa-search to create that boundary. The application declares a SearchDefinition<T> with its public fields, internal paths, allowed operators, validation rules, and limits. Only after the request passes those checks does the library return a Specification<T> and a safe Pageable ready to execute.
In this guide, we will build a complete product search. We will begin with the shortest path to a working endpoint and then cover filtering, public aliases, relationships, free-text search, pagination, sorting, errors, limits, mandatory predicates, and advanced extensions.
The examples use rsql-jpa-search 2.0.0. This version requires Java 17 or later and targets Spring Boot 4.
In this guide
1. Advanced search fundamentals in Spring Boot and JPA
How advanced search works with Spring Boot and JPA
The library neither replaces Spring Data JPA nor executes a query. Its responsibility ends after it compiles untrusted input into two validated artifacts.
- A
Specification<T>containing the RSQL filter, text search, and mandatory application constraints. - A validated and bounded
Pageablewhose public sorting aliases have been translated.
The complete flow looks like this.
filter + query + Pageable
|
v
SearchCompiler <----- SearchDefinition<T>
| trusted contract
v
CompiledSearch<T>
| |
v v
Specification safe Pageable
\ /
repository.findAll(...)
That distinction matters. Text received over HTTP is input. The definition belongs to the application and acts as policy. The compiler should not have to guess which one is authoritative.
The application remains responsible for authenticating the user, authorizing the use case, creating indexes, applying timeouts, and executing the repository. The library does not turn dynamic filters into a complete defense against abuse either. Its limits are one layer in a strategy that should also include rate limiting and observability.
The JPA model we will query
Our example is a catalog where each user manages the products they created. The Product entity contains public information, useful search relationships, and internal data that must not appear in the search contract.
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false)
private String name;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal price;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ProductStatus status;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Category category;
@OneToMany(mappedBy = "product")
private List<Review> reviews = new ArrayList<>();
@Column(nullable = false)
private UUID createdBy;
@Column(nullable = false)
private boolean deleted;
protected Product() {
}
}
In this example, sku, name, price, status, the category name, and review ratings will be queryable. createdBy and deleted exist on the entity but will not be exposed as RSQL selectors. The application will always add them as mandatory conditions so that each user sees only their own non-deleted products.
This detail captures the central idea. A search contract is not a mirror of the persistence model.
Install rsql-jpa-search in Spring Boot
For a Spring Boot application, start with the starter published on Maven Central.
<dependency>
<groupId>io.github.ggomarighetti</groupId>
<artifactId>rsql-jpa-search-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>
The starter includes the public API, compiler, JPA metamodel validation, and the default backend based on perplexhub/rsql-jpa-specification. It also configures SearchCompiler and SearchDefinition.Factory as Spring beans.
The repository only needs to support specifications.
public interface ProductRepository
extends JpaRepository<Product, UUID>,
JpaSpecificationExecutor<Product> {
}
If you are not using Spring Boot, you can depend on the individual modules. For the usual path, the starter avoids assembling them manually.
What RSQL is and how to use it
An RSQL expression is made of selectors, operators, and arguments. A semicolon means AND, a comma means OR, and parentheses control grouping.
status==PUBLISHED
status==PUBLISHED;price=ge=100
(name=ilike=phone*,sku==PHONE-001);status==PUBLISHED
category=in=(audio,phones);price=between=(100,500)
The final expression requests products in the audio or phones categories whose price is between 100 and 500. Do not confuse the comma inside a list with the OR between comparisons. Arguments for IN and BETWEEN belong inside parentheses.
The distribution includes the following operators.
| Operation | Primary symbols | Argument count |
|---|---|---|
| Equal | == | 1 |
| Not equal | != | 1 |
| Greater than | =gt= or > | 1 |
| Greater than or equal | =ge= or >= | 1 |
| Less than | =lt= or < | 1 |
| Less than or equal | =le= or <= | 1 |
| In a list | =in= | 1 or more |
| Outside a list | =out= | 1 or more |
| Is null | =isnull= | 0 or 1 |
| Is not null | =notnull= | 0 or 1 |
| LIKE | =like= | 1 |
| NOT LIKE | =notlike= | 1 |
| Case-insensitive equal | =icase= | 1 |
| Case-insensitive LIKE | =ilike= | 1 |
| Case-insensitive NOT LIKE | =inotlike= | 1 |
| Inclusive range | =between= | 2 |
| Outside an inclusive range | =notbetween= | 2 |
Short aliases such as =ik= and =bt= are also registered, although descriptive variants usually produce APIs that are easier to read.
An operator existing in the language does not make it available for every field. The definition decides which combinations belong to the endpoint.
2. Implement dynamic filters with RSQL and JPA
Configure dynamic filters with SearchDefinition
SearchDefinition<T> is an immutable contract for one entity and one use case. Two endpoints that query Product may use different definitions when they expose different capabilities.
In Spring Boot, build it with the auto-configured factory. This also applies the global path-depth limit while the definition is being created.
@Configuration
public class ProductSearchConfiguration {
@Bean
SearchDefinition<Product> productSearchDefinition(
SearchDefinition.Factory definitions) {
return definitions.builder()
.entity(Product.class)
.fields(fields -> {
fields.add("id", UUID.class)
.filterable(filter -> filter.allow(EQUAL));
fields.add("sku", String.class)
.filterable(filter -> filter.allow(EQUAL, IN))
.sortable(sort -> sort.allow(ASC));
fields.add("name", String.class)
.filterable(filter -> filter.allow(
IGNORE_CASE,
IGNORE_CASE_LIKE))
.sortable(sort -> sort.allow(ASC, DESC));
fields.add("status", ProductStatus.class)
.filterable(filter -> filter.allow(EQUAL, IN));
fields.add("price", BigDecimal.class)
.filterable(filter -> filter.allow(
EQUAL,
GREATER_THAN_OR_EQUAL,
LESS_THAN_OR_EQUAL,
BETWEEN))
.sortable(sort -> sort.allow(ASC, DESC));
fields.add("category", String.class)
.filterable(filter -> filter
.path("category.name")
.allow(EQUAL, IGNORE_CASE))
.sortable(sort -> sort
.path("category.name")
.allow(ASC));
fields.add("rating", Integer.class)
.filterable(filter -> filter
.path("reviews.rating")
.allow(GREATER_THAN_OR_EQUAL));
})
.query(query -> query
.rule(new SizeDef().min(3).max(80))
.specification(ProductSpecifications::matchesText))
.paging(paging -> {
paging.page(page -> page.rule(new MinDef().value(0)));
paging.size(size -> size.rule(new MaxDef().value(50)));
})
.limits(limits -> limits
.filter(filter -> filter
.maxComparisons(10)
.maxInValues(20))
.paging(paging -> paging.maxSize(50)))
.build();
}
}
Static imports keep the policy readable.
import static io.github.ggomarighetti.rsqljpasearch.rsql.operator.RsqlOperators.*;
import static org.springframework.data.domain.Sort.Direction.*;
The definition above establishes several boundaries.
- The only public selectors are
id,sku,name,status,price,category, andrating. categoryis a stable alias forcategory.name.ratingcan filterreviews.rating, but it cannot sort by that collection.- Every field has its own operator allowlist.
- Free text must contain between 3 and 80 characters.
- Page size cannot exceed 50.
- The complete filter cannot contain more than 10 comparisons or an
INwith more than 20 values.
We did not declare createdBy, deleted, or any other internal attribute. A Java property does not become part of the API simply because it exists.
Map public fields to JPA paths
The call fields.add("category", String.class) declares the name and type visible to the client. Unless configured otherwise, the selector is also used as the JPA path. .path("category.name") separates those concepts.
fields.add("category", String.class)
.path("category.name")
.filterable()
.sortable();
We can also use a different path for each capability.
fields.add("customer", String.class)
.filterable(filter -> filter
.path("customer.name")
.allow(EQUAL, IGNORE_CASE_LIKE))
.sortable(sort -> sort
.path("customer.sortName")
.allow(ASC));
This decouples the API from internal refactors. The frontend keeps sending customer even if the application changes how the name is persisted or normalized.
Paths are checked against Java properties while the definition is built. In a JPA application, they are also validated against the metamodel the first time the definition is compiled. A typo such as category.namme becomes an application configuration error rather than a broken query discovered by a user.
Choose which fields can filter, sort, and search
Adding a field only declares metadata. It does not enable filtering or sorting.
fields.add("name", String.class);
.filterable() enables the restrictive default profile for the type. .sortable() allows ASC and DESC. .searchable() is a shortcut that enables both.
fields.add("name", String.class).searchable();
One subtle difference deserves attention. When you call .filterable(filter -> ...), the customizer starts with an empty allowlist. Defaults are retained only when you request them explicitly.
fields.add("price", BigDecimal.class)
.filterable(filter -> filter
.withDefaults()
.deny(IN, NOT_IN)
.allow(IS_NULL));
Default profiles depend on the field type.
| Field type | Default profile |
|---|---|
| Text | Equality, lists, LIKE, and case-insensitive variants |
| Boolean | Equality and inequality |
| Enum, UUID, and exact scalar values | Equality and lists |
| Numbers and temporal types | Equality, lists, ordering, and ranges |
IS_NULL and NOT_NULL are never enabled by default. Nullability is part of the contract and should be exposed deliberately.
Sorting can restrict directions and Spring Data features.
fields.add("name", String.class)
.sortable(sort -> sort
.allow(ASC)
.allowIgnoreCase()
.allowNullHandling(Sort.NullHandling.NULLS_LAST));
A local definition cannot accidentally widen a global restriction. If the global policy rejects ignore case, null handling, or relationship sorting, the request will still be rejected.
Validate arguments before querying JPA
Allowing an operator does not mean accepting every value. The library converts arguments to the declared type and then applies programmatic Hibernate Validator rules.
Suppose a public SKU must contain between 3 and 32 characters and use only uppercase letters, numbers, and hyphens.
fields.add("sku", String.class)
.filterable(filter -> filter
.allow(EQUAL, operator -> operator
.each(each -> each
.rule(new SizeDef().min(3).max(32))
.rule(new PatternDef()
.regexp("[A-Z0-9-]+"))))
.allow(IN, operator -> operator
.args(args -> args
.rule(new SizeDef().max(20)))
.each(each -> each
.rule(new SizeDef().min(3).max(32))
.rule(new PatternDef()
.regexp("[A-Z0-9-]+")))));
each(...) validates every converted argument. args(...) validates the entire list. That separation lets us limit the size of an IN while checking the format of every element.
Conversion happens before the rules run. If id is a UUID, price is a BigDecimal, and status is an enum, these filters are validated against their actual types.
id==4f2f720c-2064-4660-b97f-91f970333acd
price=ge=149.90
status=in=(DRAFT,PUBLISHED)
An invalid UUID or an unknown enum value produces a structured RSQL error. It does not need to reach Hibernate before failing.
If the domain uses a value object, register a Spring Converter<String, T>. The same ConversionService is used for validation and compilation with the default backend, preventing those stages from interpreting the value differently.
Compile and execute the query
With the definition ready, the use case remains small. The compiler receives the four components and returns a CompiledSearch<Product>.
@Service
@Transactional(readOnly = true)
public class ProductSearchService {
private final SearchCompiler searchCompiler;
private final SearchDefinition<Product> definition;
private final ProductRepository repository;
public ProductSearchService(
SearchCompiler searchCompiler,
SearchDefinition<Product> definition,
ProductRepository repository) {
this.searchCompiler = searchCompiler;
this.definition = definition;
this.repository = repository;
}
public Page<Product> search(
UUID currentUserId,
String filter,
String query,
Pageable pageable) {
CompiledSearch<Product> compiled = searchCompiler.compile(
filter,
query,
pageable,
definition,
ProductSpecifications.createdBy(currentUserId),
ProductSpecifications.notDeleted());
return repository.findAll(
compiled.specification(),
compiled.pageable());
}
}
Additional specifications are combined with AND. The client can use OR inside its own filter, but it cannot remove or replace mandatory conditions.
createdBy(currentUserId) is not part of the RSQL language and does not come from a request parameter. The application derives the identifier from the authenticated context and gives it directly to the compiler as a trusted specification. Even a request without filters remains restricted to products created by that user.
public final class ProductSpecifications {
private ProductSpecifications() {
}
public static Specification<Product> createdBy(UUID userId) {
return (root, query, builder) ->
builder.equal(root.get("createdBy"), userId);
}
public static Specification<Product> notDeleted() {
return (root, query, builder) ->
builder.isFalse(root.get("deleted"));
}
public static Specification<Product> matchesText(String text) {
String pattern = "%" + text.toLowerCase(Locale.ROOT) + "%";
return (root, query, builder) -> builder.or(
builder.like(builder.lower(root.get("name")), pattern),
builder.like(builder.lower(root.get("sku")), pattern));
}
}
For simplicity, matchesText uses LIKE. A large catalog may be better served by full-text search, a normalized column, or indexable functions. rsql-jpa-search validates the text and delegates persistence semantics to our Specification factory. It does not impose a search strategy.
Create the advanced search endpoint
Spring can build Pageable from page, size, and sort. The controller only transports the request to the use case.
@RestController
@RequestMapping("/api/products")
public class ProductSearchController {
private final ProductSearchService service;
private final CurrentUser currentUser;
public ProductSearchController(
ProductSearchService service,
CurrentUser currentUser) {
this.service = service;
this.currentUser = currentUser;
}
@GetMapping
Page<ProductResponse> search(
@RequestParam(required = false) String filter,
@RequestParam(required = false) String query,
@PageableDefault(size = 20) Pageable pageable) {
return service.search(
currentUser.id(),
filter,
query,
pageable)
.map(ProductResponse::from);
}
}
When testing expressions, curl --data-urlencode avoids manual URL-encoding problems.
curl --get 'http://localhost:8080/api/products' \
--data-urlencode 'filter=(name=ilike=phone*,sku==PHONE-001);status==PUBLISHED' \
--data-urlencode 'query=wireless' \
--data-urlencode 'page=0' \
--data-urlencode 'size=20' \
--data-urlencode 'sort=price,asc'
At a high level, the compiler performs the following steps.
- Validate the definition and its JPA paths.
- Check the filter length and general shape.
- Parse RSQL into a bounded tree.
- Verify selectors, operators, arity, types, rules, and costs.
- Compile the accepted tree into a
Specification. - Add the text specification and mandatory conditions.
- Validate page, size, and sorting.
- Translate public sort names into JPA paths.
The repository receives only the artifacts from the final step.
3. Security and limits for dynamic queries
Protect against dangerous dynamic queries
Consider several requests that should never execute.
costPrice=lt=100
costPrice does not exist in the definition. Whether it exists on Product is irrelevant to the contract.
createdBy==f4ae66fd-c03a-4c83-aabb-a55a065877dd
createdBy is not declared either. The application supplies the authenticated user’s identifier through a mandatory specification, so the client cannot select the owner or query another user’s products.
status==PUBLISHED,status==DRAFT,status==ARCHIVED,...
An expression can be syntactically valid and still exceed the permitted number of OR branches, comparisons, or nodes.
sku=in=(SKU-1,SKU-2,... hundreds of values)
The global limit and the field-level list rule are evaluated before JPA runs.
name=ilike=*
The default profile requires three literal characters, allows a trailing wildcard, and rejects both a leading wildcard and contains patterns. Those values are configurable, but the baseline already rejects the expression above. This does not make every LIKE cheap, although it prevents several obviously broad patterns.
sort=rating,desc
rating is enabled only for filtering. Even if sorting on reviews.rating were enabled, the default policy rejects sorting through a to-many relationship.
Global and local limits
The starter ships with a bounded profile. Among other constraints, it limits RSQL text to 4096 characters, the AST to 48 nodes and depth 8, filters to 24 comparisons, IN lists to 50 values, page size to 100, offset to 5000, and sorting to 3 orders. Unpaged queries are disabled.
Those defaults are a starting point, not a measurement of what your database supports. Tighten them according to the model, indexes, and traffic.
rsql:
jpa:
search:
rsql:
max-length: 2048
max-depth: 6
max-nodes: 32
filter:
max-comparisons: 12
max-in-values: 20
max-joined-paths: 2
max-to-many-paths: 1
text:
max-pattern-length: 60
min-literal-length: 3
allow-leading-wildcard: false
allow-trailing-wildcard: true
allow-contains: false
max-wildcards: 1
paging:
max-size: 50
max-offset: 2000
allow-unpaged: false
sorting:
max-orders: 2
allow-relation-sorting: true
max-relation-orders: 1
disallow-to-many-sorting: true
query:
max-length: 80
paths:
max-depth: 3
.limits(...) is useful for a particular use case. The customizer overlays only the changed properties on the global policy.
.limits(limits -> limits
.filter(filter -> filter.maxComparisons(6))
.paging(paging -> paging.maxSize(25)))
By contrast, .limits(SearchPolicy) replaces the global policy for that definition. It is a different tool and is best reserved for cases where the full profile should be declared explicitly.
Errors an API can return
Input failures expose stable codes and safe details. There is no need to return a raw Hibernate message or persistence exception.
| Exception | Example codes | Typical origin |
|---|---|---|
RsqlFilterValidationException | RSQL_PARSE_ERROR, RSQL_RULES_FORBIDDEN, RSQL_LIMIT_EXCEEDED | Syntax, selector, operator, or argument |
SearchPageableValidationException | PAGE_RULES_FORBIDDEN, SORT_LIMIT_EXCEEDED | Page, size, or sorting |
SearchQueryValidationException | QUERY_RULES_FORBIDDEN | Free text |
SearchProtectionException | SEARCH_PROTECTION_RULE_EXCEEDED | Cross-cutting limit |
SearchDefinitionValidationException | JPA_PATH_UNRESOLVED, RSQL_OPERATOR_NOT_REGISTERED | Application configuration |
A @RestControllerAdvice can map the first four groups to HTTP 400.
@RestControllerAdvice
public class SearchExceptionHandler {
@ExceptionHandler(RsqlFilterValidationException.class)
ResponseEntity<ApiError> handleRsql(
RsqlFilterValidationException exception) {
return ResponseEntity.badRequest().body(ApiError.validation(
exception.code(),
exception.getMessage(),
exception.errors()));
}
@ExceptionHandler(SearchPageableValidationException.class)
ResponseEntity<ApiError> handlePageable(
SearchPageableValidationException exception) {
return ResponseEntity.badRequest().body(ApiError.validation(
exception.code(),
exception.getMessage(),
exception.violations()));
}
@ExceptionHandler(SearchQueryValidationException.class)
ResponseEntity<ApiError> handleQuery(
SearchQueryValidationException exception) {
return ResponseEntity.badRequest().body(ApiError.validation(
exception.code(),
exception.getMessage(),
exception.violations()));
}
@ExceptionHandler(SearchProtectionException.class)
ResponseEntity<ApiError> handleProtection(
SearchProtectionException exception) {
return ResponseEntity.badRequest().body(ApiError.validation(
exception.code(),
exception.getMessage(),
Map.of(
"rule", exception.rule(),
"actual", exception.actual(),
"limit", exception.limit())));
}
}
RsqlValidationError can identify the selector, operator, AST position, argument index, constraint, and validation path. RuleViolation deliberately omits the invalid value. This makes useful responses possible without reflecting sensitive input.
SearchDefinitionValidationException deserves different treatment. A missing path, incompatible custom operator, or invalid configuration is an application defect. It should fail during development, startup, or first use rather than becoming an alleged client error.
4. Relationships, types, and advanced extensions
Resolve to-many relationships and duplicates with distinct
The rating selector points to the reviews.rating collection.
fields.add("rating", Integer.class)
.filterable(filter -> filter
.path("reviews.rating")
.allow(GREATER_THAN_OR_EQUAL));
This request looks for products with at least one four-star review.
rating=ge=4
A to-many join can duplicate the root row. The library detects that topology and applies distinct(true) when the policy requires it. It also counts collection paths when enforcing limits.
The difficulty does not end there. A Page<T> usually runs a COUNT(*), and counts involving joins or distinct can be expensive. The paging policy controls whether those combinations are accepted.
When the consumer only needs to know whether another page exists, compile for Slice.
CompiledSearch<Product> compiled = searchCompiler.compileSlice(
filter,
query,
pageable,
definition,
ProductSpecifications.createdBy(currentUserId),
ProductSpecifications.notDeleted());
compileSlice applies limits for that mode, but it does not magically execute a count-free query. The application must pass the artifacts to an implementation that actually returns a Slice without issuing the count.
Definitions for subtypes
Fields that exist only on a subtype can be declared with .subtype(...). The backend uses treat to resolve them.
fields.add("birthDate", LocalDate.class)
.subtype(NaturalPerson.class)
.filterable()
.sortable();
This is useful in JPA hierarchies where the endpoint queries the base class, but it should not become an excuse to expose the entire inheritance tree. The selector still requires an explicit decision in the definition.
Convert domain-specific types
Imagine the domain models a SKU as a value object.
public record Sku(String value) {
}
Teach Spring how to convert the public argument.
@Component
public class SkuConverter implements Converter<String, Sku> {
@Override
public Sku convert(String source) {
if (!source.matches("SKU-[A-Z0-9]+")) {
throw new IllegalArgumentException("Invalid SKU");
}
return new Sku(source);
}
}
Then declare the real type on the field.
fields.add("sku", Sku.class)
.filterable(filter -> filter.allow(EQUAL));
If the application exposes a single ConversionService, auto-configuration uses it. Otherwise, it creates an ApplicationConversionService and adds the available Converter beans. A conversion failure becomes RSQL_ARGUMENT_CONVERSION_FAILED before it reaches persistence.
Custom operators
The everyday API is deliberately small, but the dialect can be extended. Adding =startsWith= requires a logical identifier, symbol, arity, type, and JPA predicate.
@Configuration
public class SearchOperatorsConfiguration {
static final RsqlOperator STARTS_WITH =
RsqlOperator.of("STARTS_WITH");
@Bean
SearchRsqlEngineCustomizer startsWithOperator() {
return builder -> builder.operator(
RsqlOperatorDescriptor.builder(STARTS_WITH)
.symbol("=startsWith=")
.arity(RsqlOperatorArity.exact(1))
.argumentType(String.class)
.jpaPredicate(context ->
context.criteriaBuilder().like(
context.path().as(String.class),
context.argument(0) + "%"))
.build());
}
}
Registration does not enable it for every field. Add it to the relevant allowlist.
fields.add("sku", String.class)
.filterable(filter -> filter.allow(STARTS_WITH));
A custom operator is a privileged extension point. The default backend requires a jpaPredicate, a compatible argumentType, and a Comparable type. If the operation needs different semantics, implementing a custom RsqlBackendAdapter may be the correct path.
Operational decisions worth knowing
A definition is meant to be reused. Building it on every request recreates validators and repeats work that does not depend on the user. A singleton bean like the one in our example can live for the full application lifecycle.
SearchDefinition<T> implements AutoCloseable because it owns Hibernate Validator resources. If you generate disposable dynamic definitions, call close() after the final use. Static definitions or long-lived managed beans can remain open until the application stops.
A request without filter does not remove business constraints. The compiler uses an unrestricted specification for that piece and still combines query, ownership, visibility, and every other mandatory predicate.
Unpaged searches are disabled by default. Even when enabled, the compiler does not return an unlimited Pageable.unpaged(). It turns the request into a bounded first page using default-unpaged-size and translates public sorting aliases. Enabling the unpaged form therefore does not allow the repository to read the entire table.
The starter uses Perplexhub as its JPA backend. Two common options can be configured without replacing it.
rsql:
jpa:
search:
rsql:
perplexhub:
strict-equality: true
like-escape-character: "!"
Strict equality avoids delegating ambiguous semantics to the backend, while the escape character controls how special LIKE characters are interpreted.
More advanced requirements can use focused extension points.
| Extension point | When to use it |
|---|---|
SearchRsqlEngineCustomizer | Register operators or change the auto-configured dialect |
ConversionService | Convert arguments into domain-specific types |
SearchDefinitionValidator | Add internal checks for complete definitions |
RsqlParserFactory | Replace parser construction |
RsqlBackendAdapter | Compile the validated AST with another backend |
These extensions live on the trusted side of the boundary. A custom backend or predicate can bypass guarantees the application expects when implemented unsafely, so it deserves the same level of review as a persistence layer.
It is also valid to declare multiple definitions for the same entity. An administrative endpoint may filter by more states than a public search. Sharing Product.class does not require sharing the contract. Identify those beans with @Qualifier or encapsulate each definition in its use case.
5. Test and run advanced search in production
Test dynamic filters
The most valuable tests do more than verify that a happy query returns rows. They also lock down the public surface and its security constraints.
@SpringBootTest
class ProductSearchIT {
@Autowired SearchCompiler compiler;
@Autowired SearchDefinition<Product> definition;
@Autowired ProductRepository repository;
@Test
void compilesAnAllowedFilter() {
CompiledSearch<Product> result = compiler.compile(
"status==PUBLISHED;price=ge=100",
null,
PageRequest.of(0, 20, Sort.by("price")),
definition);
assertThat(result.specification()).isNotNull();
assertThat(result.pageable().getPageSize()).isEqualTo(20);
}
@Test
void rejectsAnInternalField() {
assertThatThrownBy(() -> compiler.compile(
"createdBy==f4ae66fd-c03a-4c83-aabb-a55a065877dd",
null,
PageRequest.of(0, 20),
definition))
.isInstanceOf(RsqlFilterValidationException.class);
}
@Test
void rejectsAnOversizedPage() {
assertThatThrownBy(() -> compiler.compile(
null,
null,
PageRequest.of(0, 500),
definition))
.isInstanceOf(SearchPageableValidationException.class);
}
@Test
void returnsOnlyProductsCreatedByTheCurrentUser() {
UUID currentUserId = UUID.fromString(
"8f4a45d2-e2bd-4302-94bf-ff51f334ef11");
CompiledSearch<Product> result = compiler.compile(
"status==PUBLISHED",
null,
PageRequest.of(0, 20),
definition,
ProductSpecifications.createdBy(currentUserId),
ProductSpecifications.notDeleted());
Page<Product> products = repository.findAll(
result.specification(),
result.pageable());
assertThat(products.getContent())
.extracting("createdBy")
.containsOnly(currentUserId);
}
}
The final test assumes fixtures owned by more than one user. That mixture matters because a fixture containing only the current user’s products cannot prove that the mandatory rule excludes foreign resources.
In a real project, I would add at least the following tests.
- Every public selector accepts only its documented operators.
- Internal attributes and undeclared paths are rejected.
- UUIDs, enums, dates, and value objects fail with controlled conversion errors.
- Limits for
IN,ORbranches, wildcards, pages, and offsets are enforced. - Filtering and sorting aliases reach the intended path.
- Ownership, visibility, and soft-delete specifications are always applied.
- To-many filters do not duplicate entities.
- HTTP responses do not leak SQL, stack traces, or sensitive values.
The final two points require integration tests against a real database. Compiling a specification in memory does not prove which SQL Hibernate executes or which plan PostgreSQL chooses.
A short production checklist
Before publishing the endpoint, review these decisions.
- Every selector corresponds to a client requirement, not merely an available property.
- Public names are stable aliases rather than accidentally exposed internal paths.
- Every operator makes sense for its type and use case.
IS_NULL, leading wildcards, relationships, and to-many paths are enabled only when necessary.- Ownership, authorization, visibility, and soft deletion are added as mandatory predicates.
- Page size, offset, comparisons, joins,
ORbranches, and lists have measured limits. - Queried columns have appropriate indexes, and plans have been checked with representative data.
- Input errors map to HTTP 400 without exposing persistence details.
- The endpoint has authentication, rate limiting, timeouts, and metrics in addition to library validation.
- The version and filtering contract are documented for frontend or external consumers.
When not to use RSQL
RSQL works well when an endpoint must combine dynamic filters over a known contract. Not every endpoint needs a query language.
For two fixed parameters, an explicit DTO is probably clearer. For aggregations, facets, relevance, large text volumes, or distributed search, Elasticsearch, OpenSearch, or an analytical solution may be a better fit. Complex reporting may call for a dedicated read model instead of allowing more joins from the same transactional endpoint.
The library does not make expensive model queries cheap. An allowlist prevents unauthorized capabilities, and a limit reduces the worst accepted input. Neither replaces index design or SQL plan observation.
6. Conclusion
RSQL is appealing because it gives clients a compact language for combining conditions. Its risk appears when flexibility is mistaken for direct access to the model.
rsql-jpa-search places a contract between the two. SearchDefinition<T> decides which selectors exist, how they map, which operators they accept, how they are validated, and how much work a request may demand. SearchCompiler applies that contract and returns artifacts Spring Data JPA already knows how to execute.
In our catalog, the client can combine name, SKU, status, price, category, and reviews without knowing the schema. It cannot choose the owner, bypass soft deletion, invent a path, or request an unlimited query. That asymmetry is exactly what we want.
RSQL provides the language. The application keeps the policy.