Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,17 @@ public class Profile {

```java
@GetMapping("/users")
@WebQuery(entityClass = User.class)
public Page<User> search(
@RsqlSpec(entityClass = User.class) Specification<User> spec,
@RestrictedPageable(entityClass = User.class) Pageable pageable
@RsqlSpec Specification<User> spec,
@RestrictedPageable Pageable pageable
) {
return userRepository.findAll(spec, pageable);
}
```

`@WebQuery` is required on the controller method when using `@RsqlSpec` and/or `@RestrictedPageable`.

### 3. Example Queries

| Feature | Query |
Expand All @@ -132,19 +135,26 @@ public Page<User> search(
### Field Mapping (Aliases)

```java
@RsqlSpec(
@WebQuery(
entityClass = User.class,
fieldMappings = {
@FieldMapping(name = "joined", field = "createdAt", allowOriginalFieldName = false)
}
) Specification<User> spec
)
public Page<User> search(
@RsqlSpec Specification<User> spec,
@RestrictedPageable Pageable pageable
) {
return userRepository.findAll(spec, pageable);
}
```

- `name`: The alias to be used in the query.
- `field`: The actual entity field path.
- `allowOriginalFieldName`: If `true`, both the alias and original field name can be used. If `false` (default), only the alias is allowed.
- `allowOriginalFieldName`: If `true`, both the alias and original field name can be used. If `false` (default), only the alias is allowed for both filtering and sorting.

Query: `/users?filter=joined=gt=2024-01-01T00:00:00Z`
Sort query: `/users?sort=joined,desc`

---

Expand Down Expand Up @@ -234,6 +244,7 @@ The library provides a hierarchy of exceptions to distinguish between client-sid
- Using an original field name when a mapping alias is required (`allowOriginalFieldName = false`).
- Malformed RSQL syntax.
- **`QueryConfigurationException`**: Thrown when the library or entity mapping is misconfigured by the developer. These should typically be treated as a `500 Internal Server Error`.
- Missing `@WebQuery` on a controller method that uses `@RsqlSpec` or `@RestrictedPageable`.
- Custom operators referenced in `@RsqlFilterable` that are not registered.
- Field mappings pointing to non-existent fields on the entity.

Expand Down Expand Up @@ -266,7 +277,7 @@ public class GlobalExceptionHandler {
## How It Works

1. Parsing: The RSQL string is parsed into an AST.
2. Validation: A custom `RSQLVisitor` traverses the AST and checks every node against the `@RsqlFilterable` configuration on the target entity.
2. Validation: A custom `RSQLVisitor` traverses the AST and checks every node against the `@RsqlFilterable` configuration on the target entity defined by `@WebQuery`.
3. Reflection: `ReflectionUtil` resolves dot-notation paths, handling JPA associations and collection types.
4. Specification: Once validated, it is converted into a `Specification<T>` compatible with Spring Data JPA `findAll(Specification, Pageable)`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public class RestrictedPageableAutoConfig implements WebMvcConfigurer {

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(restrictedPageableArgumentResolver());
// Ensure this resolver is checked before Spring Data's default Pageable resolver.
resolvers.addFirst(restrictedPageableArgumentResolver());
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package in.co.akshitbansal.springwebquery;

import in.co.akshitbansal.springwebquery.annotation.FieldMapping;
import in.co.akshitbansal.springwebquery.annotation.RestrictedPageable;
import in.co.akshitbansal.springwebquery.annotation.Sortable;
import in.co.akshitbansal.springwebquery.annotation.WebQuery;
import in.co.akshitbansal.springwebquery.exception.QueryValidationException;
import in.co.akshitbansal.springwebquery.util.AnnotationUtil;
import in.co.akshitbansal.springwebquery.util.ReflectionUtil;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
Expand All @@ -17,6 +21,10 @@

import java.lang.reflect.Field;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* A custom {@link HandlerMethodArgumentResolver} that wraps a standard
Expand All @@ -32,8 +40,10 @@
* The resolver delegates the initial parsing of {@code page}, {@code size},
* and {@code sort} parameters to Spring's {@link PageableHandlerMethodArgumentResolver}.
* It then validates each requested {@link Sort.Order} against the target entity class
* specified in the {@link RestrictedPageable} annotation. Sorting is only allowed
* specified in {@link WebQuery} on the controller method. Sorting is only allowed
* on fields explicitly annotated with {@link Sortable}.
* Alias mappings from {@link WebQuery#fieldMappings()} are also applied so API-facing
* sort names can be rewritten to entity field paths.
* <p>
* If a requested sort field is not annotated as {@link Sortable}, a
* {@link QueryValidationException} is thrown.
Expand Down Expand Up @@ -71,16 +81,18 @@ public boolean supportsParameter(MethodParameter parameter) {
* The process is as follows:
* <ol>
* <li>Delegate parsing of page, size, and sort parameters to {@link #delegate}.</li>
* <li>Retrieve the target entity class from the {@link RestrictedPageable} annotation.</li>
* <li>Resolve {@link WebQuery} metadata from the controller method.</li>
* <li>Validate each requested {@link Sort.Order} against the entity's sortable fields.
* If a field is not annotated with {@link Sortable}, a {@link QueryValidationException} is thrown.</li>
* <li>Rewrite alias sort properties to real entity field paths using field mappings.</li>
* </ol>
*
* @param methodParameter the method parameter for which the value should be resolved
* @param mavContainer the ModelAndViewContainer (can be {@code null})
* @param webRequest the current request
* @param binderFactory a factory for creating WebDataBinder instances (can be {@code null})
* @return a {@link Pageable} object containing page, size, and validated sort information
* @return a {@link Pageable} object containing page, size, validated sort information,
* and mapped sort field paths
* @throws QueryValidationException if any requested sort field is not marked as {@link Sortable}
*/
@Override
Expand All @@ -90,31 +102,65 @@ public Pageable resolveArgument(
@NonNull NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
// Resolve the @WebQuery annotation to access entity metadata for validation
WebQuery webQueryAnnotation = AnnotationUtil.resolveWebQueryFromParameter(methodParameter);
// Extract entity class and field mappings from the @WebQuery annotation
Class<?> entityClass = webQueryAnnotation.entityClass();
FieldMapping[] fieldMappings = webQueryAnnotation.fieldMappings();

// Create maps for quick lookup of field mappings by both API name and original field name
Map<String, FieldMapping> fieldMappingMap = Arrays
.stream(fieldMappings)
.collect(Collectors.toMap(FieldMapping::name, mapping -> mapping));
Map<String, FieldMapping> originalFieldNameMap = Arrays
.stream(fieldMappings)
.collect(Collectors.toMap(FieldMapping::field, mapping -> mapping));

// Delegate parsing of page, size and sort parameters to Spring
Pageable pageable = delegate.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory);

// Retrieve annotation to determine the target entity
RestrictedPageable restrictedPageable = methodParameter.getParameterAnnotation(RestrictedPageable.class);
Class<?> entityClass = restrictedPageable.entityClass();

// Validate each requested sort order against entity metadata
for(Sort.Order order : pageable.getSort()) {
String fieldName = order.getProperty();

// If the field name corresponds to an API alias that does not allow using the original field name, reject it
FieldMapping originalFieldMapping = originalFieldNameMap.get(fieldName);
if(originalFieldMapping != null && !originalFieldMapping.allowOriginalFieldName())
throw new QueryValidationException(MessageFormat.format(
"Unknown field ''{0}''", fieldName
));

FieldMapping fieldMapping = fieldMappingMap.get(fieldName);
String reqFieldName = fieldName; // Storing field name present in req for error messages
if(fieldMapping != null) fieldName = fieldMapping.field();

// Resolve the field on the entity (including inherited fields)
Field field;
try {
field = ReflectionUtil.resolveField(entityClass, fieldName);
}
catch (Exception ex) {
throw new QueryValidationException(MessageFormat.format(
"Unknown field ''{0}''", fieldName
"Unknown field ''{0}''", reqFieldName
), ex);
}
// Reject sorting on fields not explicitly marked as sortable
if(!field.isAnnotationPresent(Sortable.class))
throw new QueryValidationException(MessageFormat.format("Sorting is not allowed on the field ''{0}''", fieldName));
throw new QueryValidationException(MessageFormat.format("Sorting is not allowed on the field ''{0}''", reqFieldName));
}

return pageable;
List<Sort.Order> newOrders = pageable
.getSort()
.stream()
.map(order -> {
String fieldName = order.getProperty();
FieldMapping fieldMapping = fieldMappingMap.get(fieldName);
if(fieldMapping != null) fieldName = fieldMapping.field();
return new Sort.Order(order.getDirection(), fieldName);
})
.toList();
Sort sort = Sort.by(newOrders);
if(pageable.isUnpaged()) return Pageable.unpaged(sort);
return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,26 @@
import in.co.akshitbansal.springwebquery.annotation.FieldMapping;
import in.co.akshitbansal.springwebquery.annotation.RsqlFilterable;
import in.co.akshitbansal.springwebquery.annotation.RsqlSpec;
import in.co.akshitbansal.springwebquery.exception.QueryException;
import in.co.akshitbansal.springwebquery.annotation.WebQuery;
import in.co.akshitbansal.springwebquery.exception.QueryValidationException;
import in.co.akshitbansal.springwebquery.operator.RsqlCustomOperator;
import in.co.akshitbansal.springwebquery.operator.RsqlOperator;
import in.co.akshitbansal.springwebquery.util.AnnotationUtil;
import io.github.perplexhub.rsql.QuerySupport;
import io.github.perplexhub.rsql.RSQLCustomPredicate;
import io.github.perplexhub.rsql.RSQLCustomPredicateInput;
import io.github.perplexhub.rsql.RSQLJPASupport;
import jakarta.persistence.criteria.Predicate;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import java.util.*;
import java.util.function.Function;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand All @@ -38,6 +38,7 @@
* This resolver enables transparent usage of RSQL queries in controller methods.
* When a request contains an RSQL query parameter, the resolver:
* <ol>
* <li>Resolves entity metadata and aliases from {@link WebQuery} on the controller method</li>
* <li>Parses the RSQL query string into an AST</li>
* <li>Validates the AST against the target entity using {@link ValidationRSQLVisitor}</li>
* <li>Converts the validated query into a {@link Specification} using
Expand All @@ -50,8 +51,9 @@
* <p><b>Example controller usage:</b></p>
* <pre>{@code
* @GetMapping("/users")
* @WebQuery(entityClass = User.class)
* public List<User> search(
* @RsqlSpec(entityClass = User.class) Specification<User> spec
* @RsqlSpec Specification<User> spec
* ) {
* return userRepository.findAll(spec);
* }
Expand Down Expand Up @@ -136,8 +138,9 @@ public boolean supportsParameter(MethodParameter parameter) {
* Resolves the controller method argument into a {@link Specification}.
* <p>
* The RSQL query is read from the request parameter defined by
* {@link RsqlSpec#paramName()}. The query is then parsed, validated,
* and converted into a JPA {@link Specification}.
* {@link RsqlSpec#paramName()}. Entity metadata and alias mappings are read
* from {@link WebQuery} on the same controller method. The query is then
* parsed, validated, and converted into a JPA {@link Specification}.
*
* @param parameter the method parameter to resolve
* @param mavContainer the model and view container
Expand All @@ -157,7 +160,14 @@ public Specification<?> resolveArgument(
)
{
try {
// Retrieve the @RsqlSpec annotation to access configuration
// Retrieve the @WebQuery annotation from the method parameter to access entity and field mapping configuration
WebQuery webQueryAnnotation = AnnotationUtil.resolveWebQueryFromParameter(parameter);

// Extract entity class and field mappings from the @WebQuery annotation
Class<?> entityClass = webQueryAnnotation.entityClass();
FieldMapping[] fieldMappings = webQueryAnnotation.fieldMappings();

// Retrieve the @RsqlSpec annotation from the method parameter to access parameter-specific configuration
RsqlSpec annotation = parameter.getParameterAnnotation(RsqlSpec.class);
// Extract the RSQL query string from the request using the configured parameter name
String filter = webRequest.getParameter(annotation.paramName());
Expand All @@ -168,28 +178,28 @@ public Specification<?> resolveArgument(
Node root = rsqlParser.parse(filter);
// Validate the parsed AST against the target entity and its @RsqlFilterable fields
ValidationRSQLVisitor validationVisitor = new ValidationRSQLVisitor(
annotation.entityClass(),
annotation.fieldMappings(),
entityClass,
fieldMappings,
customOperators
);
root.accept(validationVisitor);

// Convert field mappings to aliases map which rsql jpa support library accepts
Map<String, String> fieldMappings = Arrays
.stream(annotation.fieldMappings())
Map<String, String> fieldMappingsMap = Arrays
.stream(fieldMappings)
.collect(Collectors.toMap(FieldMapping::name, FieldMapping::field));

// Convert the validated RSQL query into a JPA Specification
QuerySupport querySupport = QuerySupport
.builder()
.rsqlQuery(filter)
.propertyPathMapper(fieldMappings)
.propertyPathMapper(fieldMappingsMap)
.customPredicates(customPredicates)
// prevents wildcard parsing for string equality operator
// so that "name==John*" is treated as: name equals 'John*'
// rather than: name starts with 'John'
.strictEquality(true)
.build();;
.build();
return RSQLJPASupport.toSpecification(querySupport);
}
catch (RSQLParserException ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
* <p><b>Usage example:</b></p>
* <pre>{@code
* Node root = new RSQLParser().parse("status==ACTIVE;age>30");
* new ValidationRSQLVisitor(User.class).visit(root);
* new ValidationRSQLVisitor(User.class, new FieldMapping[0], Set.of()).visit(root);
* }</pre>
*
* <p>This visitor is typically used in combination with
Expand Down Expand Up @@ -166,7 +166,7 @@ private void validate(ComparisonNode node) {
}
catch (Exception ex) {
throw new QueryValidationException(MessageFormat.format(
"Unknown field ''{0}''", fieldName
"Unknown field ''{0}''", reqFieldName
), ex);
}

Expand Down
Loading