diff --git a/README.md b/README.md index 5f52111..52d4c8c 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,17 @@ public class Profile { ```java @GetMapping("/users") +@WebQuery(entityClass = User.class) public Page search( - @RsqlSpec(entityClass = User.class) Specification spec, - @RestrictedPageable(entityClass = User.class) Pageable pageable + @RsqlSpec Specification 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 | @@ -132,19 +135,26 @@ public Page search( ### Field Mapping (Aliases) ```java -@RsqlSpec( +@WebQuery( entityClass = User.class, fieldMappings = { @FieldMapping(name = "joined", field = "createdAt", allowOriginalFieldName = false) } -) Specification spec +) +public Page search( + @RsqlSpec Specification 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` --- @@ -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. @@ -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` compatible with Spring Data JPA `findAll(Specification, Pageable)`. diff --git a/spring-boot-starter-web-query/src/main/java/in/co/akshitbansal/springwebquery/config/RestrictedPageableAutoConfig.java b/spring-boot-starter-web-query/src/main/java/in/co/akshitbansal/springwebquery/config/RestrictedPageableAutoConfig.java index ae5e7fe..f76cd60 100644 --- a/spring-boot-starter-web-query/src/main/java/in/co/akshitbansal/springwebquery/config/RestrictedPageableAutoConfig.java +++ b/spring-boot-starter-web-query/src/main/java/in/co/akshitbansal/springwebquery/config/RestrictedPageableAutoConfig.java @@ -21,7 +21,8 @@ public class RestrictedPageableAutoConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(restrictedPageableArgumentResolver()); + // Ensure this resolver is checked before Spring Data's default Pageable resolver. + resolvers.addFirst(restrictedPageableArgumentResolver()); } @Bean diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolver.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolver.java index 53f7463..114749a 100644 --- a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolver.java +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolver.java @@ -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; @@ -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 @@ -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. *

* If a requested sort field is not annotated as {@link Sortable}, a * {@link QueryValidationException} is thrown. @@ -71,16 +81,18 @@ public boolean supportsParameter(MethodParameter parameter) { * The process is as follows: *

    *
  1. Delegate parsing of page, size, and sort parameters to {@link #delegate}.
  2. - *
  3. Retrieve the target entity class from the {@link RestrictedPageable} annotation.
  4. + *
  5. Resolve {@link WebQuery} metadata from the controller method.
  6. *
  7. 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.
  8. + *
  9. Rewrite alias sort properties to real entity field paths using field mappings.
  10. *
* * @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 @@ -90,16 +102,38 @@ 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 fieldMappingMap = Arrays + .stream(fieldMappings) + .collect(Collectors.toMap(FieldMapping::name, mapping -> mapping)); + Map 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 { @@ -107,14 +141,26 @@ public Pageable resolveArgument( } 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 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); } } diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolver.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolver.java index 70f293e..76d5e76 100644 --- a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolver.java +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolver.java @@ -7,17 +7,15 @@ 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; @@ -25,8 +23,10 @@ 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; @@ -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: *
    + *
  1. Resolves entity metadata and aliases from {@link WebQuery} on the controller method
  2. *
  3. Parses the RSQL query string into an AST
  4. *
  5. Validates the AST against the target entity using {@link ValidationRSQLVisitor}
  6. *
  7. Converts the validated query into a {@link Specification} using @@ -50,8 +51,9 @@ *

    Example controller usage:

    *
    {@code
      * @GetMapping("/users")
    + * @WebQuery(entityClass = User.class)
      * public List search(
    - *     @RsqlSpec(entityClass = User.class) Specification spec
    + *     @RsqlSpec Specification spec
      * ) {
      *     return userRepository.findAll(spec);
      * }
    @@ -136,8 +138,9 @@ public boolean supportsParameter(MethodParameter parameter) {
          * Resolves the controller method argument into a {@link Specification}.
          * 

    * 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 @@ -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()); @@ -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 fieldMappings = Arrays - .stream(annotation.fieldMappings()) + Map 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) { diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitor.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitor.java index 1d3739d..420c7d4 100644 --- a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitor.java +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitor.java @@ -36,7 +36,7 @@ *

    Usage example:

    *
    {@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);
      * }
    * *

    This visitor is typically used in combination with @@ -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); } diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/FieldMapping.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/FieldMapping.java index abd79f9..3192b31 100644 --- a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/FieldMapping.java +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/FieldMapping.java @@ -5,25 +5,27 @@ import java.lang.annotation.Target; /** - * Defines a mapping between a query parameter field name and an actual entity field name. + * Defines a mapping between an API-facing field name and an actual entity field name. *

    - * This annotation is used within {@link RsqlSpec#fieldMappings()} to create aliases - * for entity fields in RSQL queries. It allows clients to use simpler or more - * intuitive names in query strings while mapping them to the actual field names on - * the entity. + * This annotation is used within {@link WebQuery#fieldMappings()} to create aliases + * for entity fields in filtering and sorting requests. It allows clients to use + * simpler or more intuitive names while mapping them to the actual field names + * on the entity. *

    * *

    Example usage:

    *
    {@code
      * @GetMapping("/users")
    + * @WebQuery(
    + *     entityClass = User.class,
    + *     fieldMappings = {
    + *         @FieldMapping(name = "id", field = "userId"),
    + *         @FieldMapping(name = "fullName", field = "profile.name")
    + *     }
    + * )
      * public List search(
    - *     @RsqlSpec(
    - *         entityClass = User.class,
    - *         fieldMappings = {
    - *             @FieldMapping(name = "id", field = "userId"),
    - *             @FieldMapping(name = "fullName", field = "profile.name")
    - *         }
    - *     ) Specification spec
    + *     @RsqlSpec Specification spec,
    + *     @RestrictedPageable Pageable pageable
      * ) {
      *     return userRepository.findAll(spec);
      * }
    @@ -33,14 +35,14 @@
      * in their RSQL queries, which will be translated to {@code userId==123} and
      * {@code profile.name==John} respectively.

    * - * @see RsqlSpec + * @see WebQuery */ @Retention(RetentionPolicy.RUNTIME) @Target({}) public @interface FieldMapping { /** - * The alias name to use in RSQL query strings. + * The alias name to use in API query strings. *

    * This is the name that clients will use when constructing their queries. *

    @@ -64,8 +66,8 @@ * Whether to allow the use of the original field name in addition to the alias. *

    * When {@code false} (default), only the alias name defined in {@link #name()} - * can be used in queries. When {@code true}, both the alias and the original - * field name are allowed. + * can be used in filter and sort expressions. When {@code true}, both the alias + * and the original field name are allowed. *

    * * @return {@code true} if the original field name should remain usable, diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RestrictedPageable.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RestrictedPageable.java index fa01738..9172ae4 100644 --- a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RestrictedPageable.java +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RestrictedPageable.java @@ -7,7 +7,9 @@ * as subject to field-level sorting restrictions. * *

    When applied, the pageable argument is validated so that sorting is only - * allowed on entity fields explicitly annotated with {@link Sortable}.

    + * allowed on entity fields explicitly annotated with {@link Sortable}. Entity + * metadata and alias mappings are resolved from {@link WebQuery} on the same + * controller method.

    * *

    This annotation does not affect pagination parameters * such as page number or page size. It only governs which fields may be used @@ -21,8 +23,9 @@ * *

    {@code
      * @GetMapping
    + * @WebQuery(entityClass = User.class)
      * public Page search(
    - *     @RestrictedPageable(entityClass = User.class) Pageable pageable
    + *     @RestrictedPageable Pageable pageable
      * ) {
      *     ...
      * }
    @@ -33,14 +36,4 @@
     @Documented
     public @interface RestrictedPageable {
     
    -    /**
    -     * Entity class whose fields define the set of properties
    -     * that may be used for sorting.
    -     *
    -     * 

    Only fields annotated with {@link Sortable} on this entity - * are permitted in the {@code sort} request parameter.

    - * - * @return the entity class used for sort validation - */ - Class entityClass(); } diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RsqlSpec.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RsqlSpec.java index 78f3093..7be07b5 100644 --- a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RsqlSpec.java +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RsqlSpec.java @@ -9,14 +9,16 @@ * When applied to a method parameter, the annotated parameter will receive a Specification * built from the RSQL query provided in the HTTP request. The RSQL query is parsed, * validated against the target entity's {@link RsqlFilterable} annotations, and converted - * into a Spring Data JPA {@link org.springframework.data.jpa.domain.Specification}. + * into a Spring Data JPA {@link org.springframework.data.jpa.domain.Specification}. Entity + * metadata and alias mappings are sourced from {@link WebQuery} on the same controller method. *

    * *

    Example usage in a controller:

    *
    {@code
      * @GetMapping("/users")
    + * @WebQuery(entityClass = User.class)
      * public List search(
    - *     @RsqlSpec(entityClass = User.class, paramName = "filter") Specification spec
    + *     @RsqlSpec(paramName = "filter") Specification spec
      * ) {
      *     return userRepository.findAll(spec);
      * }
    @@ -33,25 +35,6 @@
     @Documented
     public @interface RsqlSpec {
     
    -    /**
    -     * The entity class for which the Specification should be built.
    -     * This class is used to validate the RSQL query fields and operators
    -     * against the {@link RsqlFilterable} annotations.
    -     *
    -     * @return the target entity class
    -     */
    -    Class entityClass();
    -
    -    /**
    -     * Optional field mappings for aliasing entity fields.
    -     * 

    - * Allows mapping query parameter field names (aliases) to actual entity field names. - *

    - * - * @return an array of {@link FieldMapping} annotations - */ - FieldMapping[] fieldMappings() default {}; - /** * The name of the query parameter that contains the RSQL string. * Defaults to "filter". diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/WebQuery.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/WebQuery.java new file mode 100644 index 0000000..fd5be7e --- /dev/null +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/WebQuery.java @@ -0,0 +1,35 @@ +package in.co.akshitbansal.springwebquery.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares shared web-query metadata for a controller method. + *

    + * This annotation is intended to be placed on handler methods so query-related + * configuration can be defined once and reused by both filtering and sorting + * argument resolvers. + *

    + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface WebQuery { + + /** + * Entity class against which filter and sort fields are resolved. + * + * @return target entity class + */ + Class entityClass(); + + /** + * Optional field mappings used to expose API-facing aliases for entity fields. + * + * @return mappings between API names and entity paths + */ + FieldMapping[] fieldMappings() default {}; +} diff --git a/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/util/AnnotationUtil.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/util/AnnotationUtil.java new file mode 100644 index 0000000..af20f93 --- /dev/null +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/util/AnnotationUtil.java @@ -0,0 +1,41 @@ +package in.co.akshitbansal.springwebquery.util; + +import in.co.akshitbansal.springwebquery.annotation.WebQuery; +import in.co.akshitbansal.springwebquery.exception.QueryConfigurationException; +import lombok.NonNull; +import org.springframework.core.MethodParameter; + +import java.lang.reflect.Method; +import java.text.MessageFormat; + +/** + * Utility methods for resolving query-related annotations from controller metadata. + */ +public class AnnotationUtil { + + /** + * Resolves {@link WebQuery} from the controller method that declares the + * provided Spring MVC method parameter. + * + * @param parameter controller method parameter currently being resolved + * @return resolved {@link WebQuery} annotation + * @throws QueryConfigurationException if the method cannot be resolved or is not annotated with {@link WebQuery} + */ + public static WebQuery resolveWebQueryFromParameter(@NonNull MethodParameter parameter) { + // Retrieve the controller method + Method controllerMethod = parameter.getMethod(); + // Ensure that the method is not null (should not happen for valid controller parameters) + if(controllerMethod == null) throw new QueryConfigurationException(MessageFormat.format( + "Unable to resolve controller method for parameter {0}", parameter + )); + // Retrieve the @WebQuery annotation from the controller method to access query configuration + WebQuery webQueryAnnotation = controllerMethod.getAnnotation(WebQuery.class); + // Ensure that the method is annotated with @WebQuery to access query configuration + if(webQueryAnnotation == null) + throw new QueryConfigurationException(MessageFormat.format( + "Controller method {0} must be annotated with @WebQuery to use query argument resolvers", + controllerMethod + )); + return webQueryAnnotation; + } +} diff --git a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java index eee85f0..81f7661 100644 --- a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java +++ b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java @@ -1,7 +1,10 @@ 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.QueryConfigurationException; import in.co.akshitbansal.springwebquery.exception.QueryValidationException; import org.junit.jupiter.api.Test; import org.springframework.core.MethodParameter; @@ -46,6 +49,16 @@ void resolveArgument_allowsSortableField() throws NoSuchMethodException { assertEquals("name", pageable.getSort().iterator().next().getProperty()); } + @Test + void resolveArgument_rewritesMappedAliasField() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("searchWithMapping", Pageable.class); + MethodParameter parameter = new MethodParameter(method, 0); + NativeWebRequest request = requestWithSort("displayName,asc"); + + Pageable pageable = resolver.resolveArgument(parameter, null, request, null); + assertEquals("name", pageable.getSort().iterator().next().getProperty()); + } + @Test void resolveArgument_rejectsNonSortableField() throws NoSuchMethodException { Method method = TestController.class.getDeclaredMethod("search", Pageable.class); @@ -55,6 +68,34 @@ void resolveArgument_rejectsNonSortableField() throws NoSuchMethodException { assertThrows(QueryValidationException.class, () -> resolver.resolveArgument(parameter, null, request, null)); } + @Test + void resolveArgument_rejectsOriginalMappedFieldWhenNotAllowed() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("searchWithMapping", Pageable.class); + MethodParameter parameter = new MethodParameter(method, 0); + NativeWebRequest request = requestWithSort("name,asc"); + + assertThrows(QueryValidationException.class, () -> resolver.resolveArgument(parameter, null, request, null)); + } + + @Test + void resolveArgument_allowsOriginalMappedFieldWhenAllowed() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("searchWithMappingAllowOriginal", Pageable.class); + MethodParameter parameter = new MethodParameter(method, 0); + NativeWebRequest request = requestWithSort("name,asc"); + + Pageable pageable = resolver.resolveArgument(parameter, null, request, null); + assertEquals("name", pageable.getSort().iterator().next().getProperty()); + } + + @Test + void resolveArgument_rejectsWhenWebQueryMissing() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("searchWithoutWebQuery", Pageable.class); + MethodParameter parameter = new MethodParameter(method, 0); + NativeWebRequest request = requestWithSort("name,asc"); + + assertThrows(QueryConfigurationException.class, () -> resolver.resolveArgument(parameter, null, request, null)); + } + private static NativeWebRequest requestWithSort(String sort) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setParameter("sort", sort); @@ -63,16 +104,37 @@ private static NativeWebRequest requestWithSort(String sort) { @SuppressWarnings("unused") private static class TestController { - void search(@RestrictedPageable(entityClass = SortEntity.class) Pageable pageable) { + + @WebQuery(entityClass = SortEntity.class) + void search(@RestrictedPageable Pageable pageable) { } void searchWithoutAnnotation(Pageable pageable) { } + + @WebQuery( + entityClass = SortEntity.class, + fieldMappings = {@FieldMapping(name = "displayName", field = "name")} + ) + void searchWithMapping(@RestrictedPageable Pageable pageable) { + } + + @WebQuery( + entityClass = SortEntity.class, + fieldMappings = {@FieldMapping(name = "displayName", field = "name", allowOriginalFieldName = true)} + ) + void searchWithMappingAllowOriginal(@RestrictedPageable Pageable pageable) { + } + + void searchWithoutWebQuery(@RestrictedPageable Pageable pageable) { + } } private static class SortEntity { + @Sortable private String name; + private String secret; } } diff --git a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java index 974722d..9d5d710 100644 --- a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java +++ b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java @@ -4,6 +4,8 @@ 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.annotation.WebQuery; +import in.co.akshitbansal.springwebquery.exception.QueryConfigurationException; import in.co.akshitbansal.springwebquery.exception.QueryValidationException; import in.co.akshitbansal.springwebquery.operator.RsqlCustomOperator; import in.co.akshitbansal.springwebquery.operator.RsqlOperator; @@ -50,7 +52,8 @@ void resolveArgument_returnsUnrestrictedWhenFilterMissing() throws NoSuchMethodE Method method = TestController.class.getDeclaredMethod("search", Specification.class); MethodParameter parameter = new MethodParameter(method, 0); - resolver.resolveArgument(parameter, null, emptyRequest(), null); + Specification spec = resolver.resolveArgument(parameter, null, emptyRequest(), null); + assertNotNull(spec); } @Test @@ -102,6 +105,25 @@ void resolveArgument_allowsCustomOperator() throws NoSuchMethodException { resolverWithCustom.resolveArgument(parameter, null, webRequest, null); } + @Test + void resolveArgument_usesCustomParameterName() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("searchWithCustomParam", Specification.class); + MethodParameter parameter = new MethodParameter(method, 0); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("q", "name==john"); + + resolver.resolveArgument(parameter, null, new ServletWebRequest(request), null); + } + + @Test + void resolveArgument_rejectsWhenWebQueryMissing() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("searchWithoutWebQuery", Specification.class); + MethodParameter parameter = new MethodParameter(method, 0); + NativeWebRequest webRequest = requestWithFilter("name==john"); + + assertThrows(QueryConfigurationException.class, () -> resolver.resolveArgument(parameter, null, webRequest, null)); + } + private static NativeWebRequest emptyRequest() { return new ServletWebRequest(new MockHttpServletRequest()); } @@ -132,38 +154,53 @@ public Predicate toPredicate(RSQLCustomPredicateInput input) { @SuppressWarnings("unused") private static class TestController { - void search(@RsqlSpec(entityClass = TestEntity.class) Specification specification) { + + @WebQuery(entityClass = TestEntity.class) + void search(@RsqlSpec Specification specification) { } void searchWithoutAnnotation(Specification specification) { } - void searchWithMapping(@RsqlSpec( + @WebQuery( entityClass = TestEntity.class, fieldMappings = { @FieldMapping(name = "displayName", field = "name") } - ) Specification specification) { + ) + void searchWithMapping(@RsqlSpec Specification specification) { } - void searchWithMappingAllowOriginal(@RsqlSpec( + @WebQuery( entityClass = TestEntity.class, fieldMappings = { @FieldMapping(name = "displayName", field = "name", allowOriginalFieldName = true) } - ) Specification specification) { + ) + void searchWithMappingAllowOriginal(@RsqlSpec Specification specification) { + } + + @WebQuery(entityClass = TestEntityWithCustom.class) + void searchWithCustom(@RsqlSpec Specification specification) { } - void searchWithCustom(@RsqlSpec(entityClass = TestEntityWithCustom.class) Specification specification) { + @WebQuery(entityClass = TestEntity.class) + void searchWithCustomParam(@RsqlSpec(paramName = "q") Specification specification) { + } + + void searchWithoutWebQuery(@RsqlSpec Specification specification) { } } private static class TestEntity { + @RsqlFilterable(operators = {RsqlOperator.EQUAL}) + private String name; } private static class TestEntityWithCustom { + @RsqlFilterable(operators = {RsqlOperator.EQUAL}, customOperators = {MockCustomOperator.class}) private String name; } diff --git a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitorTest.java b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitorTest.java index 7e5c27a..6ba7d41 100644 --- a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitorTest.java +++ b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/ValidationRSQLVisitorTest.java @@ -70,6 +70,40 @@ void visit_rejectsDisallowedOperator() { assertEquals("Operator '!=' not allowed on field 'name'", ex.getMessage()); } + @Test + void visit_allowsMappedAliasField() { + ValidationRSQLVisitor visitor = new ValidationRSQLVisitor( + TestEntity.class, + new FieldMapping[]{mapping("displayName", "name", false)}, + Collections.emptySet() + ); + parser.parse("displayName==john").accept(visitor); + } + + @Test + void visit_rejectsOriginalMappedFieldWhenNotAllowed() { + ValidationRSQLVisitor visitor = new ValidationRSQLVisitor( + TestEntity.class, + new FieldMapping[]{mapping("displayName", "name", false)}, + Collections.emptySet() + ); + QueryValidationException ex = assertThrows( + QueryValidationException.class, + () -> parser.parse("name==john").accept(visitor) + ); + assertEquals("Unknown field 'name'", ex.getMessage()); + } + + @Test + void visit_allowsOriginalMappedFieldWhenAllowed() { + ValidationRSQLVisitor visitor = new ValidationRSQLVisitor( + TestEntity.class, + new FieldMapping[]{mapping("displayName", "name", true)}, + Collections.emptySet() + ); + parser.parse("name==john").accept(visitor); + } + @Test void visit_allowsCustomOperator() { Set> customOperators = Set.of(new MockCustomOperator()); @@ -108,6 +142,7 @@ public Predicate toPredicate(RSQLCustomPredicateInput input) { } private static class TestEntity { + @RsqlFilterable(operators = {RsqlOperator.EQUAL}) private String name; @@ -115,7 +150,33 @@ private static class TestEntity { } private static class TestEntityWithCustom { + @RsqlFilterable(operators = {RsqlOperator.EQUAL}, customOperators = {MockCustomOperator.class}) private String name; } + + private static FieldMapping mapping(String name, String field, boolean allowOriginalFieldName) { + return new FieldMapping() { + + @Override + public String name() { + return name; + } + + @Override + public String field() { + return field; + } + + @Override + public boolean allowOriginalFieldName() { + return allowOriginalFieldName; + } + + @Override + public Class annotationType() { + return FieldMapping.class; + } + }; + } } diff --git a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/util/AnnotationUtilTest.java b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/util/AnnotationUtilTest.java new file mode 100644 index 0000000..236dde4 --- /dev/null +++ b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/util/AnnotationUtilTest.java @@ -0,0 +1,48 @@ +package in.co.akshitbansal.springwebquery.util; + +import in.co.akshitbansal.springwebquery.annotation.RsqlSpec; +import in.co.akshitbansal.springwebquery.annotation.WebQuery; +import in.co.akshitbansal.springwebquery.exception.QueryConfigurationException; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.data.jpa.domain.Specification; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AnnotationUtilTest { + + @Test + void resolveWebQueryFromParameter_returnsAnnotation() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("search", Specification.class); + MethodParameter parameter = new MethodParameter(method, 0); + + WebQuery annotation = AnnotationUtil.resolveWebQueryFromParameter(parameter); + + assertEquals(TestEntity.class, annotation.entityClass()); + } + + @Test + void resolveWebQueryFromParameter_throwsWhenMissing() throws NoSuchMethodException { + Method method = TestController.class.getDeclaredMethod("searchWithoutWebQuery", Specification.class); + MethodParameter parameter = new MethodParameter(method, 0); + + assertThrows(QueryConfigurationException.class, () -> AnnotationUtil.resolveWebQueryFromParameter(parameter)); + } + + @SuppressWarnings("unused") + private static class TestController { + + @WebQuery(entityClass = TestEntity.class) + void search(@RsqlSpec Specification specification) { + } + + void searchWithoutWebQuery(@RsqlSpec Specification specification) { + } + } + + private static class TestEntity { + } +}