diff --git a/src/main/java/it/aboutbits/springboot/toolbox/parameter/PageParameter.java b/src/main/java/it/aboutbits/springboot/toolbox/parameter/PageParameter.java new file mode 100644 index 0000000..ff326bf --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/parameter/PageParameter.java @@ -0,0 +1,107 @@ +package it.aboutbits.springboot.toolbox.parameter; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +@Slf4j +@ToString +@Accessors(fluent = true) +public final class PageParameter { + @Getter + @Setter + @SuppressWarnings({"checkstyle:StaticVariableName", "java:S3008"}) + private static int MAX_PAGE_SIZE = 9999; + + @Getter + @Setter + @SuppressWarnings({"checkstyle:StaticVariableName", "java:S3008"}) + private static int DEFAULT_PAGE_SIZE = 50; + + private record PageInfo(int page, int size, boolean paged) { + } + + private final PageInfo pageInfo; + + private PageParameter(Integer page, Integer size) { + var actualPage = (page == null) ? 0 : page; + var actualSize = (size == null) ? DEFAULT_PAGE_SIZE : size; + + if (actualSize > MAX_PAGE_SIZE) { + log.warn("Page size exceeded maximum [actualSize={}, maxSize={}]", actualSize, MAX_PAGE_SIZE); + } + + pageInfo = new PageInfo( + Math.max(0, actualPage), + Math.min(actualSize, MAX_PAGE_SIZE), + true + ); + } + + private PageParameter() { + pageInfo = new PageInfo(0, Integer.MAX_VALUE, false); + } + + public static PageParameter of(Integer page) { + return new PageParameter(page, null); + } + + public static PageParameter of(Integer page, Integer size) { + return new PageParameter(page, size); + } + + public static PageParameter defaultPage() { + return new PageParameter(null, null); + } + + public static PageParameter unpaged() { + return new PageParameter(); + } + + public int page() { + return pageInfo.page(); + } + + public int size() { + return pageInfo.size(); + } + + public boolean isPaged() { + return pageInfo.paged(); + } + + public boolean isUnpaged() { + return !pageInfo.paged(); + } + + public PageRequest toPageRequest() { + return PageRequest.of(page(), size()); + } + + public PageRequest toPageRequest(Sort sort) { + return PageRequest.of(page(), size(), sort); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj instanceof PageParameter pageParameter) { + return this.pageInfo.equals(pageParameter.pageInfo); + } + return false; + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/src/main/java/it/aboutbits/springboot/toolbox/parameter/SortParameter.java b/src/main/java/it/aboutbits/springboot/toolbox/parameter/SortParameter.java new file mode 100644 index 0000000..25f9791 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/parameter/SortParameter.java @@ -0,0 +1,115 @@ +package it.aboutbits.springboot.toolbox.parameter; + +import lombok.NonNull; +import org.springframework.data.domain.Sort; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public record SortParameter & SortParameter.Definition>(List sortFields) { + private static final String DEFAULT_SORT_PROPERTY = "id"; + private static final Sort DEFAULT_SORT = Sort.by( + Sort.Direction.ASC, + DEFAULT_SORT_PROPERTY + ); + + public static & Definition> SortParameter unsorted() { + return new SortParameter<>(Collections.emptyList()); + } + + @SafeVarargs + public static & Definition> SortParameter by( + @NonNull T... sortDefinitions + ) { + return new SortParameter<>( + Stream.of(sortDefinitions) + .map(sortDefinition -> new SortField( + sortDefinition.name(), + Sort.Direction.ASC, + Sort.NullHandling.NATIVE + ) + ) + .toList() + ); + } + + public static & Definition> SortParameter by( + @NonNull T sortDefinition, + @NonNull Sort.Direction direction, + @NonNull Sort.NullHandling nullHandling + ) { + return new SortParameter<>( + List.of(new SortField( + sortDefinition.name(), + direction, + nullHandling + ) + ) + ); + } + + public SortParameter or(@NonNull SortParameter fallback) { + return sortFields == null || sortFields.isEmpty() ? fallback : this; + } + + public Sort buildSortWithoutDefault(@NonNull Map mapping) { + var stringMapping = mapping.entrySet().stream() + .collect(Collectors.toMap( + entry -> entry.getKey().name(), + Map.Entry::getValue + )); + + return buildSort(stringMapping, false); + } + + public Sort buildSort(@NonNull Map mapping) { + var stringMapping = mapping.entrySet().stream() + .collect(Collectors.toMap( + entry -> entry.getKey().name(), + Map.Entry::getValue + )); + + return buildSort(stringMapping, true); + } + + // SonarLint: Replace this usage of 'Stream.collect(Collectors.toList())' with 'Stream.toList()' and ensure that the list is unmodified. + @SuppressWarnings("java:S6204") + private Sort buildSort(@NonNull Map mapping, boolean withDefault) { + if (sortFields == null || sortFields.isEmpty()) { + return withDefault ? DEFAULT_SORT : Sort.unsorted(); + } + + var additionalSort = Sort.by( + sortFields.stream() + .filter(sortField -> mapping.containsKey(sortField.property())) + .map(sortField -> new Sort.Order( + sortField.direction(), + mapping.get(sortField.property()), + sortField.nullHandling() + )) + // We do not use .toList() here as we potentially want to modify the sort list later in the StoreImpl + .collect(Collectors.toList()) + ); + + if (withDefault) { + var includesDefault = additionalSort.getOrderFor(DEFAULT_SORT_PROPERTY) != null; + if (!includesDefault) { + return additionalSort.and(DEFAULT_SORT); + } + } + return additionalSort; + } + + public record SortField( + @NonNull String property, + @NonNull Sort.Direction direction, + @NonNull Sort.NullHandling nullHandling + ) { + } + + public interface Definition { + } +} diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/SortMappings.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/SortMappings.java new file mode 100644 index 0000000..ba59fda --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/SortMappings.java @@ -0,0 +1,35 @@ +package it.aboutbits.springboot.toolbox.persistence; + +import it.aboutbits.springboot.toolbox.parameter.SortParameter; +import lombok.NonNull; + +import java.util.HashMap; + +public final class SortMappings & SortParameter.Definition> extends HashMap { + private SortMappings() { + super(); + } + + public static & SortParameter.Definition> Mapping map( + @NonNull T property, + @NonNull String column + ) { + return new Mapping<>(property, column); + } + + @SafeVarargs + public static & SortParameter.Definition> SortMappings of( + @NonNull Mapping... mappings + ) { + var sortMappings = new SortMappings(); + + for (var mapping : mappings) { + sortMappings.put(mapping.property(), mapping.column()); + } + + return sortMappings; + } + + public record Mapping & SortParameter.Definition>(@NonNull T property, @NonNull String column) { + } +} diff --git a/src/main/java/it/aboutbits/springboot/toolbox/swagger/sort_parameter/SortParameterCustomizer.java b/src/main/java/it/aboutbits/springboot/toolbox/swagger/sort_parameter/SortParameterCustomizer.java index fe5a85a..0feaaea 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/swagger/sort_parameter/SortParameterCustomizer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/swagger/sort_parameter/SortParameterCustomizer.java @@ -3,12 +3,11 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.ArraySchema; import io.swagger.v3.oas.models.media.StringSchema; -import lombok.RequiredArgsConstructor; +import it.aboutbits.springboot.toolbox.parameter.SortParameter; import org.springdoc.core.customizers.OpenApiCustomizer; -@RequiredArgsConstructor public class SortParameterCustomizer implements OpenApiCustomizer { - private final Class sortParameterSortFieldClass; + private final String nameToMatch = "." + SortParameter.class.getSimpleName(); @Override public void customise(OpenAPI openApi) { @@ -29,7 +28,7 @@ public void customise(OpenAPI openApi) { continue; } - if (parameter.getSchema().get$ref().endsWith(".SortParameter")) { + if (parameter.getSchema().get$ref().endsWith(nameToMatch)) { parameter.required(false); parameter.description( """ @@ -85,7 +84,7 @@ public void customise(OpenAPI openApi) { ); var itemSchema = new StringSchema()._default("property:asc:last"); itemSchema.setDescription( - "{\"originalTypeFqn\": \"%s\"}".formatted(sortParameterSortFieldClass.getName()) + "{\"originalTypeFqn\": \"%s\"}".formatted(SortParameter.class.getName()) ); parameter.setSchema(new ArraySchema().items( diff --git a/src/main/java/it/aboutbits/springboot/toolbox/web/SortParameterResolver.java b/src/main/java/it/aboutbits/springboot/toolbox/web/SortParameterResolver.java new file mode 100644 index 0000000..d63d186 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/web/SortParameterResolver.java @@ -0,0 +1,81 @@ +package it.aboutbits.springboot.toolbox.web; + +import it.aboutbits.springboot.toolbox.parameter.SortParameter; +import lombok.NonNull; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.ArrayList; +import java.util.Objects; + +public class SortParameterResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return SortParameter.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + @NonNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) throws IllegalArgumentException { + var sorts = webRequest.getParameterValues( + Objects.requireNonNull(parameter.getParameterName()) + ); + + // Allow empty order list + if (sorts == null) { + return SortParameter.unsorted(); + } + + var sortFields = new ArrayList(sorts.length); + for (var order : sorts) { + var parts = order.split(":"); + if (parts.length == 1) { + sortFields.add(new SortParameter.SortField( + parts[0], + org.springframework.data.domain.Sort.DEFAULT_DIRECTION, + org.springframework.data.domain.Sort.NullHandling.NATIVE + )); + } else if (parts.length == 2) { + sortFields.add(new SortParameter.SortField( + parts[0], + org.springframework.data.domain.Sort.Direction.fromString(parts[1]), + org.springframework.data.domain.Sort.NullHandling.NATIVE + )); + } else if (parts.length == 3) { + sortFields.add(new SortParameter.SortField( + parts[0], + org.springframework.data.domain.Sort.Direction.fromString(parts[1]), + nullHandlingFromString(parts[2]) + )); + } else { + throw new IllegalArgumentException( + "Invalid sort order, only a maximum of two semicolons are allowed in format [:asc|desc][:native|first|last] -> Your input " + order + ); + } + } + + return new SortParameter<>(sortFields); + } + + private org.springframework.data.domain.Sort.NullHandling nullHandlingFromString(@NonNull String value) { + return switch (value.toLowerCase()) { + case "first": + yield org.springframework.data.domain.Sort.NullHandling.NULLS_FIRST; + case "last": + yield org.springframework.data.domain.Sort.NullHandling.NULLS_LAST; + case "native": + yield org.springframework.data.domain.Sort.NullHandling.NATIVE; + default: + throw new IllegalArgumentException( + "Only native, first or last are allowed as null handling strategy -> Your input " + value + ); + }; + } +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/parameter/SortParameterTest.java b/src/test/java/it/aboutbits/springboot/toolbox/parameter/SortParameterTest.java new file mode 100644 index 0000000..ae99eb4 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/parameter/SortParameterTest.java @@ -0,0 +1,176 @@ +package it.aboutbits.springboot.toolbox.parameter; + +import it.aboutbits.springboot.toolbox.persistence.SortMappings; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +import java.util.Collections; +import java.util.List; + +import static it.aboutbits.springboot.toolbox.persistence.SortMappings.map; +import static org.assertj.core.api.Assertions.assertThat; + +class SortParameterTest { + @SuppressWarnings("java:S115") + private enum ESort implements SortParameter.Definition { + Property1, + Property2, + property3, + Property4, + property5, + propertyOldName1, + propertyOldName2 + } + + private static final SortMappings SORT_MAPPINGS = SortMappings.of( + map(ESort.Property1, "Property1"), + map(ESort.Property2, "Property2"), + map(ESort.property3, "property3"), + map(ESort.Property4, "Property4"), + map(ESort.property5, "property5"), + map(ESort.propertyOldName1, "property.new.name1"), + map(ESort.propertyOldName2, "property.new.name2") + ); + + @Nested + class Unsorted { + @Test + void unsorted() { + var item = SortParameter.unsorted(); + + assertThat(item.sortFields()).isEmpty(); + } + } + + @Nested + class BuildSortWithoutDefault { + @Test + void nullFieldList_shouldReturnUnsorted() { + // given + var item = new SortParameter(null); + + var expected = SortParameter.unsorted().buildSortWithoutDefault(SORT_MAPPINGS); + + // when + var sort = item.buildSortWithoutDefault(SORT_MAPPINGS); + + // then + assertThat(sort).isEqualTo(Sort.unsorted()); + assertThat(sort).isEqualTo(expected); + } + + @Test + void emptySortFieldList_shouldReturnUnsorted() { + // given + var item = new SortParameter(Collections.emptyList()); + + var expected = SortParameter.unsorted().buildSortWithoutDefault(SORT_MAPPINGS); + + // when + var sort = item.buildSortWithoutDefault(SORT_MAPPINGS); + + // then + assertThat(sort).isEqualTo(Sort.unsorted()); + assertThat(sort).isEqualTo(expected); + } + } + + @Nested + class BuildSort { + @Test + void nullFieldList_shouldReturnSortedById() { + // given + var item = new SortParameter(null); + + var expected = SortParameter.unsorted().buildSort(SORT_MAPPINGS); + + // when + var sort = item.buildSort(SORT_MAPPINGS); + + // then + assertThat(sort).isEqualTo(Sort.by("id")); + assertThat(sort).isEqualTo(expected); + } + + @Test + void emptySortFieldList_shouldSortedById() { + // given + var item = new SortParameter(Collections.emptyList()); + + var expected = SortParameter.unsorted().buildSort(SORT_MAPPINGS); + + // when + var sort = item.buildSort(SORT_MAPPINGS); + + // then + assertThat(sort).isEqualTo(Sort.by("id")); + assertThat(sort).isEqualTo(expected); + } + + @Test + void sortFieldListWithoutMapping_shouldReturnSort() { + // given + var item = new SortParameter(List.of( + new SortParameter.SortField("property5", Sort.Direction.ASC, Sort.NullHandling.NULLS_FIRST), + new SortParameter.SortField("Property2", Sort.Direction.DESC, Sort.NullHandling.NATIVE), + new SortParameter.SortField("Property1", Sort.Direction.ASC, Sort.NullHandling.NULLS_FIRST), + new SortParameter.SortField("property3", Sort.Direction.DESC, Sort.NullHandling.NATIVE), + new SortParameter.SortField("Property4", Sort.Direction.DESC, Sort.NullHandling.NULLS_LAST) + )); + + // when + var sort = item.buildSort(SORT_MAPPINGS); + + // then + assertThat(sort).isEqualTo(Sort.by( + new Sort.Order(Sort.Direction.ASC, "property5", Sort.NullHandling.NULLS_FIRST), + new Sort.Order(Sort.Direction.DESC, "Property2", Sort.NullHandling.NATIVE), + new Sort.Order(Sort.Direction.ASC, "Property1", Sort.NullHandling.NULLS_FIRST), + new Sort.Order(Sort.Direction.DESC, "property3", Sort.NullHandling.NATIVE), + new Sort.Order(Sort.Direction.DESC, "Property4", Sort.NullHandling.NULLS_LAST), + new Sort.Order(Sort.Direction.ASC, "id") + )); + } + + @Test + void sortFieldListWithMapping_shouldReturnSort() { + var item = new SortParameter(List.of( + new SortParameter.SortField("propertyOldName1", Sort.Direction.ASC, Sort.NullHandling.NATIVE), + new SortParameter.SortField("propertyOldName2", Sort.Direction.DESC, Sort.NullHandling.NULLS_LAST), + new SortParameter.SortField( + "propertyNoMapping", + Sort.Direction.DESC, + Sort.NullHandling.NULLS_FIRST + ) + )); + + // when + var sort = item.buildSort(SORT_MAPPINGS); + + // then + assertThat(sort).isEqualTo(Sort.by( + new Sort.Order(Sort.Direction.ASC, "property.new.name1", Sort.NullHandling.NATIVE), + new Sort.Order(Sort.Direction.DESC, "property.new.name2", Sort.NullHandling.NULLS_LAST), + new Sort.Order(Sort.Direction.ASC, "id") + )); + } + + @Test + void sortFieldListWithNoMapping_shouldOmit() { + var item = new SortParameter(List.of( + new SortParameter.SortField( + "propertyNoMapping", + Sort.Direction.DESC, + Sort.NullHandling.NULLS_FIRST + ) + )); + + // when + var sort = item.buildSort(SORT_MAPPINGS); + + // then + assertThat(sort).isEqualTo(Sort.by("id")); + } + } +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/web/SortParameterResolverTest.java b/src/test/java/it/aboutbits/springboot/toolbox/web/SortParameterResolverTest.java new file mode 100644 index 0000000..7cd0aea --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/web/SortParameterResolverTest.java @@ -0,0 +1,402 @@ +package it.aboutbits.springboot.toolbox.web; + +import it.aboutbits.springboot.toolbox.parameter.SortParameter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SortParameterResolverTest { + @Mock + private MethodParameter methodParameter; + @Mock + private ModelAndViewContainer mavContainer; + @Mock + private NativeWebRequest webRequest; + @Mock + private WebDataBinderFactory binderFactory; + + @InjectMocks + private SortParameterResolver sut; + + @BeforeEach + void setUp() { + when(methodParameter.getParameterName()).thenReturn("sort"); + } + + @Test + @MockitoSettings(strictness = Strictness.LENIENT) + @SuppressWarnings({"unchecked", "rawtypes"}) + void supportsOrderListParameter_shouldReturnTrue() { + // given + var clazz = (Class) SortParameter.class; + when(methodParameter.getParameterType()).thenReturn(clazz); + + // then + assertThat(sut.supportsParameter(methodParameter)).isTrue(); + } + + @Test + void orderListWithNoOrderParameter_shouldReturnUnsorted() { + // given + var parameterValues = webRequest.getParameterValues("sort"); + when(parameterValues).thenReturn(null); + + // when + var result = sut.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + + // then + assertThat(result).isEqualTo(SortParameter.unsorted()); + } + + @Test + void orderListWithEmptyOrderParameterArray_shouldReturnUnsorted() { + // given + var parameterValues = webRequest.getParameterValues("sort"); + when(parameterValues).thenReturn(new String[]{}); + + // when + var result = sut.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + + // then + assertThat(result).isEqualTo(SortParameter.unsorted()); + } + + @SuppressWarnings("java:S115") + enum ESort implements SortParameter.Definition { + name, + date + } + + @Test + void orderByNameAscAndDateDesc_shouldReturnCorrectlySortedOrderList() { + // given + String[] orders = { + "name:asc", + "date:desc" + }; + + when(webRequest.getParameterValues("sort")).thenReturn(orders); + + // when + @SuppressWarnings("unchecked") + var result = (SortParameter) sut.resolveArgument( + methodParameter, + mavContainer, + webRequest, + binderFactory + ); + + // then + assertThat(result).isNotNull(); + assertThat(result.sortFields()).hasSize(2); + assertThat(result.sortFields().getFirst().property()).isEqualTo("name"); + assertThat(result.sortFields().getFirst().direction()).isEqualTo(Sort.Direction.ASC); + assertThat(result.sortFields().getFirst().nullHandling()).isEqualTo(Sort.NullHandling.NATIVE); + assertThat(result.sortFields().getLast().property()).isEqualTo("date"); + assertThat(result.sortFields().getLast().direction()).isEqualTo(Sort.Direction.DESC); + assertThat(result.sortFields().getLast().nullHandling()).isEqualTo(Sort.NullHandling.NATIVE); + } + + @ParameterizedTest + @ValueSource(strings = {"name:invalidAsc", "name:invalidDesc"}) + void notExistingOrderBySortParameter_shouldThrowIllegalArgumentException(String value) { + // given + String[] orders = {value}; + + when(webRequest.getParameterValues("sort")).thenReturn(orders); + + // then + assertThatIllegalArgumentException().isThrownBy( + () -> /* when */ sut.resolveArgument( + methodParameter, + mavContainer, + webRequest, + binderFactory + ) + ) + .withMessage( + "Invalid value '%s' for orders given; Has to be either 'desc' or 'asc' (case insensitive)".formatted( + value.split(":")[1] // We only want the second part of the order query param after the : + ) + ); + } + + @ParameterizedTest + @ValueSource(strings = {"name:asc:invalidNullHandling1", "name:desc:invalidNullHandling2"}) + void notExistingOrderByNullHandlingParameter_shouldThrowIllegalArgumentException(String value) { + // given + String[] orders = {value}; + + when(webRequest.getParameterValues("sort")).thenReturn(orders); + + // then + assertThatIllegalArgumentException().isThrownBy( + () -> /* when */ sut.resolveArgument( + methodParameter, + mavContainer, + webRequest, + binderFactory + ) + ) + .withMessage( + "Only native, first or last are allowed as null handling strategy -> Your input %s".formatted( + value.split(":")[2] + ) + ); + } + + @ParameterizedTest + @ValueSource( + strings = { + "name:asc:first:invalidAsNoFourthPartExists", + "cannot:parse:this:as:there:are:too:many:parts" + } + ) + void invalidOrderByParameter_shouldThrowIllegalArgumentException(String value) { + // given + String[] orders = {value}; + + when(webRequest.getParameterValues("sort")).thenReturn(orders); + + // then + assertThatIllegalArgumentException().isThrownBy( + () -> /* when */ sut.resolveArgument( + methodParameter, + mavContainer, + webRequest, + binderFactory + ) + ).withMessage( + """ + Invalid sort order, only a maximum of two semicolons are allowed\ + in format [:asc|desc][:native|first|last] -> Your input %s\ + """.formatted(value) + ); + } + + @ParameterizedTest + @MethodSource("provideOrderListTestCases") + void givenValidTestCases_shouldReturnCorrectlySortedOrderList( + String[] orderQueryParams, + SortParameter sortParameter + ) { + // given + when(webRequest.getParameterValues("sort")).thenReturn(orderQueryParams); + + // when + var result = (SortParameter) sut.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + + assertThat(result).isNotNull(); + assertThat(result.sortFields()).hasSize(sortParameter.sortFields().size()); + assertThat(result.sortFields()) + .isEqualTo(sortParameter.sortFields()); + } + + @SuppressWarnings("checkstyle:methodlength") + private static Stream provideOrderListTestCases() { + return Stream.of( + // null order query params list should return unsorted order list + Arguments.of( + null, + SortParameter.unsorted() + ), + // Empty order query params list should return unsorted order list + Arguments.of( + new String[]{}, + SortParameter.unsorted() + ), + Arguments.of( + new String[]{"name"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{"name:asc"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{"name:ASC"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{"name:desc"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.DESC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{"name:DESC"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.DESC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{"name:asc:first"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NULLS_FIRST + ))) + ), + Arguments.of( + new String[]{"name:asc:FIRST"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NULLS_FIRST + ))) + ), + Arguments.of( + new String[]{"name:asc:last"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NULLS_LAST + ))) + ), + Arguments.of( + new String[]{"name:asc:LAST"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NULLS_LAST + ))) + ), + Arguments.of( + new String[]{"name:asc:native"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{"name:asc:NATIVE"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.ASC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{"name:desc:first"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.DESC, + Sort.NullHandling.NULLS_FIRST + ))) + ), + Arguments.of( + new String[]{"name:desc:last"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.DESC, + Sort.NullHandling.NULLS_LAST + ))) + ), + Arguments.of( + new String[]{"name:desc:native"}, + new SortParameter<>(List.of(new SortParameter.SortField( + "name", + Sort.Direction.DESC, + Sort.NullHandling.NATIVE + ))) + ), + Arguments.of( + new String[]{ + "lastName", + "firstName:desc", + "date:DESC:LAST" + }, + new SortParameter<>(List.of( + new SortParameter.SortField("lastName", Sort.Direction.ASC, Sort.NullHandling.NATIVE), + new SortParameter.SortField( + "firstName", + Sort.Direction.DESC, + Sort.NullHandling.NATIVE + ), + new SortParameter.SortField("date", Sort.Direction.DESC, Sort.NullHandling.NULLS_LAST) + )) + ), + Arguments.of( + new String[]{ + "lastName:asc:last", + "firstName:asc:last", + "date:desc:native" + }, + new SortParameter<>(List.of( + new SortParameter.SortField( + "lastName", + Sort.Direction.ASC, + Sort.NullHandling.NULLS_LAST + ), + new SortParameter.SortField( + "firstName", + Sort.Direction.ASC, + Sort.NullHandling.NULLS_LAST + ), + new SortParameter.SortField("date", Sort.Direction.DESC, Sort.NullHandling.NATIVE) + )) + ), + Arguments.of( + new String[]{ + "col7:asc:first", + "col1:desc:native", + "col3:desc:last", + "col5:asc:last", + "col4:desc:first", + "col2:asc:native" + }, + new SortParameter<>(List.of( + new SortParameter.SortField("col7", Sort.Direction.ASC, Sort.NullHandling.NULLS_FIRST), + new SortParameter.SortField("col1", Sort.Direction.DESC, Sort.NullHandling.NATIVE), + new SortParameter.SortField("col3", Sort.Direction.DESC, Sort.NullHandling.NULLS_LAST), + new SortParameter.SortField("col5", Sort.Direction.ASC, Sort.NullHandling.NULLS_LAST), + new SortParameter.SortField( + "col4", + Sort.Direction.DESC, + Sort.NullHandling.NULLS_FIRST + ), + new SortParameter.SortField("col2", Sort.Direction.ASC, Sort.NullHandling.NATIVE) + )) + ) + ); + } +}