From 5d3f953c13fea1d3b9dae749dcd83a2ac9e502dd Mon Sep 17 00:00:00 2001 From: Akshit Bansal Date: Sat, 21 Feb 2026 16:58:08 +0530 Subject: [PATCH 1/5] Added new WebQuery annotation + using it to get metadata + aliasing now works in sorting as well --- .../config/RestrictedPageableAutoConfig.java | 3 +- ...java => AutoConfigIntegrationTest.java.in} | 0 .../RestrictedPageableArgumentResolver.java | 56 ++++++++++++++++--- .../RsqlSpecificationArgumentResolver.java | 33 ++++++----- .../springwebquery/ValidationRSQLVisitor.java | 2 +- .../annotation/RestrictedPageable.java | 10 ---- .../springwebquery/annotation/RsqlSpec.java | 19 ------- .../springwebquery/annotation/WebQuery.java | 35 ++++++++++++ .../springwebquery/util/AnnotationUtil.java | 30 ++++++++++ ...ictedPageableArgumentResolverTest.java.in} | 0 ...SpecificationArgumentResolverTest.java.in} | 0 11 files changed, 137 insertions(+), 51 deletions(-) rename spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/{AutoConfigIntegrationTest.java => AutoConfigIntegrationTest.java.in} (100%) create mode 100644 spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/WebQuery.java create mode 100644 spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/util/AnnotationUtil.java rename spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/{RestrictedPageableArgumentResolverTest.java => RestrictedPageableArgumentResolverTest.java.in} (100%) rename spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/{RsqlSpecificationArgumentResolverTest.java => RsqlSpecificationArgumentResolverTest.java.in} (100%) 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-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java b/spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java.in similarity index 100% rename from spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java rename to spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java.in 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..0097ec8 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 @@ -90,16 +98,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 +137,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..9a9dee6 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; @@ -157,7 +157,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 +175,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..09f5b7b 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 @@ -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/RestrictedPageable.java b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/annotation/RestrictedPageable.java index fa01738..ceddd97 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 @@ -33,14 +33,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..70ec806 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 @@ -33,25 +33,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..e30853a --- /dev/null +++ b/spring-web-query-core/src/main/java/in/co/akshitbansal/springwebquery/util/AnnotationUtil.java @@ -0,0 +1,30 @@ +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; + +public class AnnotationUtil { + + 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 @RsqlSpec parameters", + 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.in similarity index 100% rename from spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java rename to spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java.in 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.in similarity index 100% rename from spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java rename to spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java.in From 4a9ff9851d2a27a56410a8737f732c324d1fd713 Mon Sep 17 00:00:00 2001 From: Akshit Bansal Date: Sat, 21 Feb 2026 17:10:23 +0530 Subject: [PATCH 2/5] Corrected javadoc --- .../RestrictedPageableArgumentResolver.java | 10 ++++-- .../RsqlSpecificationArgumentResolver.java | 9 +++-- .../springwebquery/ValidationRSQLVisitor.java | 2 +- .../annotation/FieldMapping.java | 34 ++++++++++--------- .../annotation/RestrictedPageable.java | 7 ++-- .../springwebquery/annotation/RsqlSpec.java | 6 ++-- .../springwebquery/util/AnnotationUtil.java | 13 ++++++- 7 files changed, 53 insertions(+), 28 deletions(-) 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 0097ec8..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 @@ -40,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. @@ -79,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 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 9a9dee6..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 @@ -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 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 09f5b7b..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 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 ceddd97..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
      * ) {
      *     ...
      * }
    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 70ec806..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);
      * }
    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
    index e30853a..af20f93 100644
    --- 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
    @@ -8,8 +8,19 @@
     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();
    @@ -22,7 +33,7 @@ public static WebQuery resolveWebQueryFromParameter(@NonNull MethodParameter par
             // 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 @RsqlSpec parameters",
    +                    "Controller method {0} must be annotated with @WebQuery to use query argument resolvers",
                         controllerMethod
                 ));
             return webQueryAnnotation;
    
    From 551411dc7413b3a82c6012cabbe64f6ede4b57e3 Mon Sep 17 00:00:00 2001
    From: Akshit Bansal 
    Date: Sat, 21 Feb 2026 17:12:56 +0530
    Subject: [PATCH 3/5] Updated README with new behaviour details
    
    ---
     README.md | 23 +++++++++++++++++------
     1 file changed, 17 insertions(+), 6 deletions(-)
    
    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)`.
     
    
    From 1f105a011be118726226cb133336e32d40587fd4 Mon Sep 17 00:00:00 2001
    From: Akshit Bansal 
    Date: Sat, 21 Feb 2026 17:17:25 +0530
    Subject: [PATCH 4/5] Fixed core tests
    
    ---
     ...strictedPageableArgumentResolverTest.java} | 64 ++++++++++++++++++-
     ...sqlSpecificationArgumentResolverTest.java} | 51 +++++++++++++--
     .../ValidationRSQLVisitorTest.java            | 61 ++++++++++++++++++
     .../util/AnnotationUtilTest.java              | 48 ++++++++++++++
     4 files changed, 216 insertions(+), 8 deletions(-)
     rename spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/{RestrictedPageableArgumentResolverTest.java.in => RestrictedPageableArgumentResolverTest.java} (51%)
     rename spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/{RsqlSpecificationArgumentResolverTest.java.in => RsqlSpecificationArgumentResolverTest.java} (76%)
     create mode 100644 spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/util/AnnotationUtilTest.java
    
    diff --git a/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java.in b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java
    similarity index 51%
    rename from spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RestrictedPageableArgumentResolverTest.java.in
    rename to 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.in
    +++ 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.in b/spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java
    similarity index 76%
    rename from spring-web-query-core/src/test/java/in/co/akshitbansal/springwebquery/RsqlSpecificationArgumentResolverTest.java.in
    rename to 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.in
    +++ 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 {
    +    }
    +}
    
    From 9d6276a84d4313830af6281b4cb1da4d0ba29497 Mon Sep 17 00:00:00 2001
    From: Akshit Bansal 
    Date: Sat, 21 Feb 2026 17:20:55 +0530
    Subject: [PATCH 5/5] Enabled starter tests
    
    ---
     ...nfigIntegrationTest.java.in => AutoConfigIntegrationTest.java} | 0
     1 file changed, 0 insertions(+), 0 deletions(-)
     rename spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/{AutoConfigIntegrationTest.java.in => AutoConfigIntegrationTest.java} (100%)
    
    diff --git a/spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java.in b/spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java
    similarity index 100%
    rename from spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java.in
    rename to spring-boot-starter-web-query/src/test/java/in/co/akshitbansal/springwebquery/config/AutoConfigIntegrationTest.java