Cómo implementar búsqueda avanzada en Spring Boot con JPA y RSQL
Implementar búsqueda avanzada en Spring Boot suele empezar con unos pocos parámetros y terminar en un endpoint difícil de mantener. En esta guía vamos a crear filtros dinámicos con Spring Data JPA y RSQL, sumar búsqueda de texto, paginación y ordenamiento, y validar cada consulta antes de ejecutarla.
RSQL resuelve una parte del problema. En lugar de agregar un parámetro por cada variante, el cliente puede enviar una expresión como esta.
status==PUBLISHED;price=between=(100,500)
El problema difícil no es convertir ese texto en una cláusula WHERE. Es decidir si el cliente puede consultar status, qué operadores puede aplicar, cuánto puede combinar y qué restricciones nunca debería controlar.
Una integración directa entre RSQL y JPA puede terminar usando el modelo de persistencia como contrato público. Si mañana agregamos costPrice, createdBy o una relación con información sensible, no queremos que esos paths queden disponibles solamente porque existen en una entidad. Tampoco queremos descubrir en producción que alguien puede enviar un IN con miles de valores, veinte joins o una página de tamaño prácticamente ilimitado.
Desarrollé y mantengo rsql-jpa-search para agregar esa frontera. La aplicación declara una SearchDefinition<T> con los campos públicos, sus rutas internas, los operadores permitidos, las reglas de validación y los límites. Recién después de comprobar la consulta, la librería entrega una Specification<T> y un Pageable listos para ejecutar.
En esta guía vamos a construir una búsqueda de productos completa. Veremos el camino corto para ponerla a funcionar y después recorreremos filtrado, alias públicos, relaciones, texto libre, paginación, ordenamiento, errores, límites, predicados obligatorios y extensiones avanzadas.
Los ejemplos utilizan rsql-jpa-search 2.0.0. Esta versión requiere Java 17 o superior y está orientada a Spring Boot 4.
Contenido de la guía
1. Fundamentos de la búsqueda avanzada con Spring Boot y JPA
Cómo funciona la búsqueda avanzada con Spring Boot y JPA
La librería no reemplaza a Spring Data JPA ni ejecuta la consulta. Su responsabilidad termina al compilar una entrada no confiable en dos artefactos validados.
- Una
Specification<T>con el filtro RSQL, la búsqueda de texto y las condiciones obligatorias de la aplicación. - Un
Pageablevalidado, limitado y con los aliases de ordenamiento traducidos.
El flujo completo se puede resumir así.
filter + query + Pageable
|
v
SearchCompiler <----- SearchDefinition<T>
| contrato confiable
v
CompiledSearch<T>
| |
v v
Specification Pageable seguro
\ /
repository.findAll(...)
La distinción importa. El texto enviado por HTTP es input. La definición pertenece a la aplicación y funciona como política. El compilador no debería adivinar cuál de los dos tiene razón.
La aplicación todavía es responsable de autenticar al usuario, autorizar el caso de uso, crear índices, aplicar timeouts y ejecutar el repositorio. La librería tampoco convierte un filtro dinámico en una defensa completa contra abuso. Sus límites son una capa dentro de una estrategia que también debería incluir rate limiting y observabilidad.
El modelo JPA que vamos a consultar
Nuestro caso práctico es un catálogo donde cada usuario administra los productos que creó. La entidad Product tiene información pública, relaciones útiles para buscar y datos internos que no deben aparecer en el contrato.
@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() {
}
}
En el ejemplo, sku, name, price, status, el nombre de la categoría y el rating de las reseñas serán consultables. createdBy y deleted existen en la entidad, pero no estarán disponibles como selectores RSQL. La aplicación los agregará siempre como condiciones obligatorias para que cada usuario vea solamente sus propios productos no eliminados.
Ese detalle captura la idea central. El contrato de búsqueda no es un espejo del modelo.
Instalar rsql-jpa-search en Spring Boot
En una aplicación Spring Boot conviene comenzar con el starter publicado en Maven Central.
<dependency>
<groupId>io.github.ggomarighetti</groupId>
<artifactId>rsql-jpa-search-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>
El starter incluye la API pública, el compilador, la validación contra el metamodelo JPA y el backend predeterminado basado en perplexhub/rsql-jpa-specification. También configura un SearchCompiler y una SearchDefinition.Factory como beans de Spring.
El repositorio solamente necesita poder ejecutar specifications.
public interface ProductRepository
extends JpaRepository<Product, UUID>,
JpaSpecificationExecutor<Product> {
}
Si no usás Spring Boot podés depender de los módulos individuales. Para el recorrido habitual, el starter evita tener que ensamblarlos a mano.
Qué es RSQL y cómo se usa
Una expresión RSQL se compone de selectores, operadores y argumentos. El punto y coma representa AND, la coma representa OR y los paréntesis controlan la agrupación.
status==PUBLISHED
status==PUBLISHED;price=ge=100
(name=ilike=phone*,sku==PHONE-001);status==PUBLISHED
category=in=(audio,phones);price=between=(100,500)
La última expresión pide productos de las categorías audio o phones cuyo precio esté entre 100 y 500. Es importante no confundir la coma de una lista con el OR entre comparaciones. Los argumentos de IN y BETWEEN van dentro de paréntesis.
La distribución incluye estos operadores.
| Operación | Símbolos principales | Cantidad de argumentos |
|---|---|---|
| Igual | == | 1 |
| Distinto | != | 1 |
| Mayor | =gt= o > | 1 |
| Mayor o igual | =ge= o >= | 1 |
| Menor | =lt= o < | 1 |
| Menor o igual | =le= o <= | 1 |
| Dentro de una lista | =in= | 1 o más |
| Fuera de una lista | =out= | 1 o más |
| Es null | =isnull= | 0 o 1 |
| No es null | =notnull= | 0 o 1 |
| LIKE | =like= | 1 |
| NOT LIKE | =notlike= | 1 |
| Igual sin distinguir mayúsculas | =icase= | 1 |
| LIKE sin distinguir mayúsculas | =ilike= | 1 |
| NOT LIKE sin distinguir mayúsculas | =inotlike= | 1 |
| Rango inclusivo | =between= | 2 |
| Fuera de un rango inclusivo | =notbetween= | 2 |
Los aliases cortos como =ik= y =bt= también están registrados, aunque las variantes descriptivas suelen producir APIs más fáciles de leer.
Que un operador exista en el lenguaje no significa que quede habilitado para todos los campos. La definición decide qué combinaciones forman parte del endpoint.
2. Implementar filtros dinámicos con RSQL y JPA
Configurar filtros dinámicos con SearchDefinition
SearchDefinition<T> es un contrato inmutable para una entidad y un caso de uso. Dos endpoints que consultan Product pueden tener definiciones diferentes si exponen capacidades distintas.
En Spring Boot conviene construirla con la factory autoconfigurada. Así también hereda el límite global de profundidad de paths durante la construcción.
@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();
}
}
Los imports estáticos hacen que la política sea más legible.
import static io.github.ggomarighetti.rsqljpasearch.rsql.operator.RsqlOperators.*;
import static org.springframework.data.domain.Sort.Direction.*;
La definición anterior establece varias fronteras.
- Los únicos selectores públicos son
id,sku,name,status,price,categoryyrating. categoryes un alias estable paracategory.name.ratingpuede filtrarreviews.rating, pero no ordenar por esa colección.- Cada campo tiene una whitelist de operadores propia.
- El texto libre debe medir entre 3 y 80 caracteres.
- El tamaño de página no puede superar 50.
- El filtro completo no puede tener más de 10 comparaciones ni un
INmás de 20 valores.
No declaramos createdBy, deleted ni cualquier otro atributo interno. La existencia de una propiedad Java no alcanza para convertirla en parte de la API.
Mapear campos públicos a rutas JPA
La llamada fields.add("category", String.class) declara el nombre y el tipo que ve el cliente. Si no hacemos nada más, el selector también se utiliza como path JPA. .path("category.name") permite separar ambos conceptos.
fields.add("category", String.class)
.path("category.name")
.filterable()
.sortable();
También podemos utilizar un path diferente para cada capacidad.
fields.add("customer", String.class)
.filterable(filter -> filter
.path("customer.name")
.allow(EQUAL, IGNORE_CASE_LIKE))
.sortable(sort -> sort
.path("customer.sortName")
.allow(ASC));
Esto desacopla la API de refactors internos. El frontend continúa enviando customer aunque la aplicación cambie la forma de persistir o normalizar el nombre.
Los paths se comprueban primero contra las propiedades Java durante la construcción. En una aplicación JPA también se validan contra el metamodelo la primera vez que se compila la definición. Un typo como category.namme pasa a ser un error de configuración de la aplicación, no una consulta rota que descubre el usuario.
Definir qué campos se pueden filtrar, ordenar y buscar
Agregar un campo solamente declara metadata. No lo habilita para filtrar ni ordenar.
fields.add("name", String.class);
.filterable() habilita el perfil restrictivo predeterminado para el tipo. .sortable() permite ASC y DESC. .searchable() es un atajo que activa ambos.
fields.add("name", String.class).searchable();
Hay una diferencia pequeña que merece atención. Cuando llamamos a .filterable(filter -> ...), el customizer comienza con una whitelist vacía. Los defaults solamente se conservan si los pedimos de forma explícita.
fields.add("price", BigDecimal.class)
.filterable(filter -> filter
.withDefaults()
.deny(IN, NOT_IN)
.allow(IS_NULL));
Los perfiles predeterminados dependen del tipo.
| Tipo del campo | Perfil predeterminado |
|---|---|
| Texto | igualdad, listas, LIKE y variantes case-insensitive |
| Boolean | igualdad y desigualdad |
| Enum, UUID y escalares exactos | igualdad y listas |
| Números y tipos temporales | igualdad, listas, orden y rangos |
IS_NULL y NOT_NULL nunca aparecen por defecto. La nulabilidad también es parte del contrato y debe habilitarse conscientemente.
Para sorting podemos limitar direcciones y características de Spring Data.
fields.add("name", String.class)
.sortable(sort -> sort
.allow(ASC)
.allowIgnoreCase()
.allowNullHandling(Sort.NullHandling.NULLS_LAST));
La definición local no puede ampliar por accidente una prohibición global. Si la política global no acepta ignore case, null handling o sorting por relaciones, el request seguirá siendo rechazado.
Validar los argumentos antes de consultar JPA
Permitir un operador no obliga a aceptar cualquier valor. La librería convierte los argumentos al tipo declarado y después ejecuta reglas programáticas de Hibernate Validator.
Supongamos que un SKU público debe tener entre 3 y 32 caracteres y utilizar solamente mayúsculas, números y guiones.
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(...) valida cada argumento convertido. args(...) valida la lista completa. Esa separación permite limitar el tamaño de un IN y, al mismo tiempo, comprobar el formato de todos sus elementos.
La conversión ocurre antes de las reglas. Si id es un UUID, price un BigDecimal y status un enum, estos filtros se validan con sus tipos reales.
id==4f2f720c-2064-4660-b97f-91f970333acd
price=ge=149.90
status=in=(DRAFT,PUBLISHED)
Un UUID inválido o un valor de enum inexistente produce un error RSQL estructurado. No necesita llegar hasta Hibernate para fallar.
Si el dominio utiliza un value object, podemos registrar un Converter<String, T> de Spring. La misma ConversionService se utiliza para validar y para compilar con el backend predeterminado, evitando que ambas etapas interpreten el valor de manera distinta.
Compilar y ejecutar la consulta
Con la definición lista, el caso de uso queda pequeño. El compilador recibe los cuatro componentes y devuelve un 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());
}
}
Las specifications adicionales se combinan con AND. El cliente puede utilizar OR dentro de su filtro, pero no puede sacar ni reemplazar las condiciones obligatorias.
createdBy(currentUserId) no forma parte del lenguaje RSQL ni se obtiene de un parámetro del request. La aplicación deriva el identificador del contexto autenticado y lo entrega directamente al compilador como una specification confiable. Así, incluso un request sin filtros continúa limitado a los productos creados por ese usuario.
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));
}
}
Para simplificar, matchesText usa LIKE. En un catálogo grande probablemente convenga utilizar full-text search, una columna normalizada o funciones indexables. rsql-jpa-search valida el texto y delega la semántica de persistencia a nuestra factory de Specification; no impone una estrategia de búsqueda.
Crear el endpoint de búsqueda avanzada
Spring puede construir el Pageable desde page, size y sort. El controlador solamente transporta el request hacia el caso de uso.
@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);
}
}
Para probar expresiones sin pelear con el encoding de la URL, curl --data-urlencode resulta cómodo.
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'
El compilador realiza, a grandes rasgos, este recorrido.
- Valida la definición y los paths JPA.
- Comprueba la longitud y la forma general del filtro.
- Parsea RSQL a un árbol limitado.
- Verifica selectores, operadores, aridad, tipos, reglas y costos.
- Compila el árbol aceptado a una
Specification. - Agrega la specification de texto y las condiciones obligatorias.
- Valida página, tamaño y sorting.
- Traduce los nombres públicos de sort a paths JPA.
El repositorio solamente recibe los artefactos del último paso.
3. Seguridad y límites para consultas dinámicas
Proteger las consultas dinámicas peligrosas
Volvamos a algunos ejemplos que no queremos ejecutar.
costPrice=lt=100
costPrice no existe en la definición. Que exista o no en Product es irrelevante para el contrato.
createdBy==f4ae66fd-c03a-4c83-aabb-a55a065877dd
createdBy tampoco está declarado. La aplicación incorpora el identificador del usuario autenticado mediante una specification obligatoria, por lo que el cliente no puede elegir el propietario ni consultar productos ajenos.
status==PUBLISHED,status==DRAFT,status==ARCHIVED,...
Una expresión puede ser sintácticamente válida y aun así superar el número de ramas OR, comparaciones o nodos permitidos.
sku=in=(SKU-1,SKU-2,... cientos de valores)
El límite global y la regla local de la lista se evalúan antes de ejecutar JPA.
name=ilike=*
El perfil predeterminado exige tres caracteres literales, permite un wildcard final y rechaza tanto el wildcard inicial como los patrones contains. Esos valores se pueden ajustar, pero el punto de partida ya descarta la expresión anterior. Esto no convierte cualquier LIKE en una operación barata, aunque evita aceptar algunas formas obviamente amplias.
sort=rating,desc
rating está habilitado solamente para filtering. Incluso si se habilitara sorting sobre reviews.rating, la política predeterminada rechaza ordenar a través de una relación to-many.
Límites globales y límites locales
El starter trae un perfil acotado. Entre otros valores, limita el texto RSQL a 4096 caracteres, el AST a 48 nodos y profundidad 8, los filtros a 24 comparaciones, los IN a 50 valores, el tamaño de página a 100, el offset a 5000 y el sorting a 3 órdenes. Las consultas unpaged están deshabilitadas.
Esos defaults son un punto de partida, no una medición de lo que nuestra base soporta. Conviene endurecerlos de acuerdo con el modelo, los índices y el tráfico.
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
Los límites de .limits(...) sirven para un caso de uso particular. Cuando usamos el customizer se superponen únicamente las propiedades modificadas sobre la política global.
.limits(limits -> limits
.filter(filter -> filter.maxComparisons(6))
.paging(paging -> paging.maxSize(25)))
En cambio, .limits(SearchPolicy) reemplaza la política global para esa definición. Es una herramienta distinta y conviene reservarla para casos donde realmente queremos declarar el perfil completo.
Errores que una API puede devolver
Las fallas de input exponen códigos estables y detalles seguros. No hace falta devolver el mensaje crudo de Hibernate ni una excepción de persistencia.
| Excepción | Ejemplos de código | Origen habitual |
|---|---|---|
RsqlFilterValidationException | RSQL_PARSE_ERROR, RSQL_RULES_FORBIDDEN, RSQL_LIMIT_EXCEEDED | Sintaxis, selector, operador o argumento |
SearchPageableValidationException | PAGE_RULES_FORBIDDEN, SORT_LIMIT_EXCEEDED | Página, tamaño o sorting |
SearchQueryValidationException | QUERY_RULES_FORBIDDEN | Texto libre |
SearchProtectionException | SEARCH_PROTECTION_RULE_EXCEEDED | Límite transversal |
SearchDefinitionValidationException | JPA_PATH_UNRESOLVED, RSQL_OPERATOR_NOT_REGISTERED | Configuración de la aplicación |
Un @RestControllerAdvice puede mapear los primeros cuatro grupos a 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 puede identificar selector, operador, posición en el AST, índice del argumento, constraint y path de validación. RuleViolation omite deliberadamente el valor inválido. Esto permite producir respuestas útiles sin reflejar input sensible.
SearchDefinitionValidationException merece otro tratamiento. Un path inexistente, un operador custom incompatible o una configuración inválida son defectos de la aplicación. Deberían fallar en desarrollo, startup o primer uso, no convertirse en un supuesto error del cliente.
4. Relaciones, tipos y extensiones avanzadas
Resolver relaciones uno-a-muchos y duplicados con distinct
El selector rating apunta a reviews.rating, una colección.
fields.add("rating", Integer.class)
.filterable(filter -> filter
.path("reviews.rating")
.allow(GREATER_THAN_OR_EQUAL));
Este request busca productos con al menos una reseña de cuatro puntos.
rating=ge=4
Un join to-many puede duplicar la fila raíz. La librería detecta esa topología y aplica distinct(true) cuando la política lo requiere. También contabiliza los paths de colección para hacer cumplir sus límites.
La dificultad no termina ahí. Una Page<T> suele ejecutar un COUNT(*), y los counts con joins o distinct pueden ser costosos. La política de página controla si esas combinaciones se aceptan.
Cuando el consumidor solamente necesita saber si existe una página siguiente, podemos compilar para Slice.
CompiledSearch<Product> compiled = searchCompiler.compileSlice(
filter,
query,
pageable,
definition,
ProductSpecifications.createdBy(currentUserId),
ProductSpecifications.notDeleted());
compileSlice aplica límites específicos para ese modo, pero no ejecuta mágicamente una consulta sin count. La aplicación debe pasar los artefactos a una implementación que realmente devuelva un Slice sin emitir el count.
Definiciones para subtipos
Los campos que existen solamente en un subtipo se pueden declarar con .subtype(...). El backend utiliza treat para resolverlos.
fields.add("birthDate", LocalDate.class)
.subtype(NaturalPerson.class)
.filterable()
.sortable();
Esto resulta útil en jerarquías JPA donde el endpoint consulta la clase base, aunque no debería convertirse en una excusa para exponer toda la herencia. El selector sigue necesitando una decisión explícita en la definición.
Conversión de tipos propios
Imaginemos que el dominio modela un SKU como value object.
public record Sku(String value) {
}
Podemos enseñar a Spring a convertir el argumento público.
@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);
}
}
Después declaramos el tipo real en el field.
fields.add("sku", Sku.class)
.filterable(filter -> filter.allow(EQUAL));
Si la aplicación expone una única ConversionService, la autoconfiguración la utiliza. En caso contrario crea una ApplicationConversionService e incorpora los beans Converter disponibles. Un fallo de conversión se representa como RSQL_ARGUMENT_CONVERSION_FAILED antes de llegar a la capa de persistencia.
Operadores personalizados
La API cotidiana es intencionalmente pequeña, pero el dialecto puede ampliarse. Para agregar =startsWith= necesitamos declarar un identificador lógico, símbolo, aridad, tipo y predicado JPA.
@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());
}
}
Registrarlo no lo habilita en todos los campos. Todavía debemos agregarlo a la whitelist correspondiente.
fields.add("sku", String.class)
.filterable(filter -> filter.allow(STARTS_WITH));
El custom operator es un punto de extensión privilegiado. El backend predeterminado exige un jpaPredicate, un argumentType compatible y un tipo Comparable. Si nuestra operación necesita otra semántica, el camino correcto puede ser implementar un RsqlBackendAdapter propio.
Decisiones operativas que conviene conocer
Una definición está pensada para reutilizarse. Construirla en cada request recrea validadores y repite un trabajo que no depende del usuario. Un bean singleton, como el de nuestro ejemplo, puede vivir durante todo el ciclo de la aplicación.
SearchDefinition<T> implementa AutoCloseable porque posee recursos de Hibernate Validator. Si generás definiciones dinámicas y descartables, llamá a close() después del último uso. Las definiciones estáticas o administradas como beans de larga vida pueden permanecer abiertas hasta que termine la aplicación.
Un request sin filter no elimina las condiciones de negocio. El compilador utiliza una specification sin restricciones para esa pieza y todavía combina query, propietario, visibilidad y cualquier otro predicate obligatorio.
Las búsquedas unpaged están deshabilitadas por defecto. Incluso cuando se habilitan, el compilador no devuelve un Pageable.unpaged() ilimitado. Convierte el request a una primera página acotada por default-unpaged-size y traduce los aliases de sort. De esa manera, habilitar la forma unpaged no equivale a permitir que el repositorio lea toda la tabla.
El starter usa Perplexhub como backend de JPA. Dos opciones frecuentes se pueden configurar sin reemplazarlo.
rsql:
jpa:
search:
rsql:
perplexhub:
strict-equality: true
like-escape-character: "!"
La igualdad estricta evita delegar semánticas ambiguas al backend y el escape permite controlar cómo se interpretan caracteres especiales de LIKE.
Para necesidades más profundas existen puntos de extensión específicos.
| Punto de extensión | Cuándo usarlo |
|---|---|
SearchRsqlEngineCustomizer | Registrar operadores o cambiar el dialecto autoconfigurado |
ConversionService | Convertir argumentos a tipos propios |
SearchDefinitionValidator | Agregar comprobaciones internas sobre definiciones completas |
RsqlParserFactory | Reemplazar la construcción del parser |
RsqlBackendAdapter | Compilar el AST validado con otro backend |
Estas extensiones viven del lado confiable de la frontera. Un backend o predicado custom puede saltarse las garantías que la aplicación espera si se implementa de forma insegura, por lo que necesita el mismo nivel de revisión que una capa de persistencia.
También es válido declarar varias definiciones para la misma entidad. Un endpoint administrativo quizá pueda filtrar por más estados que la búsqueda pública. Compartir Product.class no obliga a compartir el contrato. En ese caso conviene identificar los beans con @Qualifier o encapsular cada definición dentro de su caso de uso.
5. Probar y llevar la búsqueda avanzada a producción
Cómo probar los filtros dinámicos
Los tests más valiosos no verifican solamente que una consulta feliz devuelva filas. También fijan la superficie pública y las restricciones de seguridad.
@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);
}
}
El último test supone datos de prueba pertenecientes a más de un usuario. Esa mezcla es importante, porque un fixture que contiene solamente productos propios no puede demostrar que la regla obligatoria excluye recursos ajenos.
En un proyecto real agregaría, como mínimo, estas pruebas.
- Cada selector público acepta solamente sus operadores documentados.
- Los atributos internos y paths no declarados son rechazados.
- UUID, enum, fechas y value objects fallan con errores de conversión controlados.
- Los límites de
IN, ramasOR, wildcards, página y offset se cumplen. - Los aliases de filtering y sorting llegan al path correcto.
- Las specifications de propietario, visibilidad y borrado lógico siempre se aplican.
- Los filtros to-many no duplican entidades.
- La respuesta HTTP no filtra SQL, stack traces ni valores sensibles.
Los últimos dos puntos necesitan tests de integración contra una base real. Compilar una specification en memoria no demuestra qué SQL ejecuta Hibernate ni qué plan elegirá PostgreSQL.
Una lista breve para producción
Antes de publicar el endpoint revisaría estas decisiones.
- Cada selector corresponde a una necesidad del cliente y no solamente a una propiedad disponible.
- Los nombres públicos son aliases estables y no paths internos filtrados accidentalmente.
- Cada operador tiene sentido para el tipo y para el caso de uso.
IS_NULL, wildcards iniciales, relaciones y to-many se habilitan únicamente cuando son necesarios.- Propietario, autorización, visibilidad y borrado lógico se agregan como predicates obligatorios.
- Página, offset, cantidad de comparaciones, joins, ramas
ORy listas tienen límites medidos. - Las columnas consultadas cuentan con índices apropiados y los planes se verificaron con datos representativos.
- Los errores de input se traducen a HTTP 400 sin exponer detalles de persistencia.
- El endpoint tiene autenticación, rate limiting, timeouts y métricas además de la validación de la librería.
- La versión y el contrato de filtros se documentan para el frontend o consumidores externos.
Cuándo no usar RSQL
RSQL funciona bien cuando un endpoint necesita combinar filtros dinámicos sobre un contrato conocido. No todos los endpoints necesitan un lenguaje de consultas.
Para dos parámetros fijos, un DTO explícito probablemente sea más claro. Para agregaciones, facets, relevancia, grandes volúmenes de texto o búsqueda distribuida, Elasticsearch, OpenSearch o una solución analítica pueden encajar mejor. Para reportes complejos quizá convenga un read model específico en lugar de permitir más joins desde el mismo endpoint transaccional.
La librería tampoco vuelve baratas las consultas que el modelo hace costosas. Una whitelist evita capacidades no autorizadas; un límite reduce el peor input aceptado; ninguno reemplaza el diseño de índices ni la observación de los planes SQL.
6. Conclusión
El atractivo de RSQL está en darle al cliente un lenguaje compacto para combinar condiciones. Su riesgo aparece cuando interpretamos flexibilidad como acceso directo al modelo.
rsql-jpa-search coloca un contrato entre ambas cosas. SearchDefinition<T> decide qué selectores existen, cómo se traducen, qué operadores aceptan, cómo se validan y cuánto trabajo puede solicitar un request. SearchCompiler aplica ese contrato y devuelve artefactos que Spring Data JPA ya sabe ejecutar.
En nuestro catálogo, el cliente puede combinar nombre, SKU, estado, precio, categoría y reseñas sin conocer el esquema. No puede elegir el propietario, sacar el borrado lógico, inventar un path ni pedir una consulta sin límites. Esa asimetría es exactamente lo que buscamos.
RSQL aporta el idioma. La aplicación conserva la política.