diff --git a/application/src/main/java/run/halo/app/infra/utils/SortUtils.java b/application/src/main/java/run/halo/app/infra/utils/SortUtils.java new file mode 100644 index 000000000..e600f7805 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/SortUtils.java @@ -0,0 +1,43 @@ +package run.halo.app.infra.utils; + +import java.util.List; +import lombok.experimental.UtilityClass; +import org.springframework.data.domain.Sort; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +@UtilityClass +public class SortUtils { + static final String delimiter = ","; + + /** + *

Resolve from direction params, e.g. "name,asc" or "name"

+ * + * @param directionParams direction params + * @return sort object + */ + public static Sort resolve(List directionParams) { + if (CollectionUtils.isEmpty(directionParams)) { + return Sort.unsorted(); + } + Sort.Order[] orders = new Sort.Order[directionParams.size()]; + for (int i = 0; i < directionParams.size(); i++) { + String[] parts = directionParams.get(i).split(delimiter); + if (parts.length == 1) { + orders[i] = new Sort.Order(Sort.Direction.ASC, parts[0]); + } else { + orders[i] = new Sort.Order(toDirection(parts[1]), parts[0]); + } + } + return Sort.by(orders); + } + + private static Sort.Direction toDirection(@NonNull String direction) { + Assert.notNull(direction, "Direction must not be null"); + if (direction.contains(" ")) { + throw new IllegalArgumentException("Direction must not contain whitespace"); + } + return Sort.Direction.fromString(direction); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/PostFinder.java b/application/src/main/java/run/halo/app/theme/finders/PostFinder.java index 834797610..1a469715d 100644 --- a/application/src/main/java/run/halo/app/theme/finders/PostFinder.java +++ b/application/src/main/java/run/halo/app/theme/finders/PostFinder.java @@ -1,10 +1,12 @@ package run.halo.app.theme.finders; +import java.util.Map; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; +import run.halo.app.theme.finders.impl.PostFinderImpl.PostQuery; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.NavigationPostVo; @@ -20,8 +22,7 @@ import run.halo.app.theme.finders.vo.PostVo; public interface PostFinder { /** - * Gets post detail by name. - *

+ *

Gets post detail by name.

* We ensure the post is public, non-deleted and published. * * @param postName is post name @@ -35,6 +36,13 @@ public interface PostFinder { Flux listAll(); + /** + * Lists posts by query params. + * + * @param params query params see {@link PostQuery} + */ + Mono> list(Map params); + Mono> list(@Nullable Integer page, @Nullable Integer size); Mono> listByCategory(@Nullable Integer page, @Nullable Integer size, diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java index e53b5e02e..0af92012c 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java @@ -8,8 +8,10 @@ import static run.halo.app.extension.index.query.QueryFactory.notEqual; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import lombok.AllArgsConstructor; +import lombok.Data; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; @@ -23,6 +25,7 @@ import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; +import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; @@ -31,6 +34,8 @@ import run.halo.app.extension.index.query.QueryFactory; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.LabelSelector; import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.infra.utils.SortUtils; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.PostPublicQueryService; @@ -146,6 +151,25 @@ public class PostFinderImpl implements PostFinder { return notEqual("status.hideFromList", BooleanUtils.TRUE); } + @Override + public Mono> list(Map params) { + var query = Optional.ofNullable(params) + .map(map -> JsonUtils.mapToObject(map, PostQuery.class)) + .orElseGet(PostQuery::new); + if (StringUtils.isNotBlank(query.getCategoryName())) { + return listChildrenCategories(query.getCategoryName()) + .map(category -> category.getMetadata().getName()) + .collectList() + .map(categoryNames -> ListOptions.builder(query.toListOptions()) + .andQuery(in("spec.categories", categoryNames)) + .build() + ) + .flatMap( + listOptions -> postPublicQueryService.list(listOptions, query.toPageRequest())); + } + return postPublicQueryService.list(query.toListOptions(), query.toPageRequest()); + } + @Override public Mono> list(Integer page, Integer size) { var listOptions = ListOptions.builder() @@ -270,14 +294,54 @@ public class PostFinderImpl implements PostFinder { .concatMap(postPublicQueryService::convertToListedVo); } - int pageNullSafe(Integer page) { + static int pageNullSafe(Integer page) { return ObjectUtils.defaultIfNull(page, 1); } - int sizeNullSafe(Integer size) { + static int sizeNullSafe(Integer size) { return ObjectUtils.defaultIfNull(size, 10); } record LinkNavigation(String prev, String current, String next) { } + + @Data + public static class PostQuery { + private Integer page; + private Integer size; + private String categoryName; + private String tagName; + private String owner; + private List sort; + + public ListOptions toListOptions() { + var builder = ListOptions.builder(); + var hasQuery = false; + if (StringUtils.isNotBlank(owner)) { + builder.andQuery(equal("spec.owner", owner)); + hasQuery = true; + } + if (StringUtils.isNotBlank(tagName)) { + builder.andQuery(equal("spec.tags", tagName)); + hasQuery = true; + } + if (StringUtils.isNotBlank(categoryName)) { + builder.andQuery(in("spec.categories", categoryName)); + hasQuery = true; + } + // Exclude hidden posts when no query + if (!hasQuery) { + builder.fieldQuery(notHiddenPostQuery()); + } + return builder.build(); + } + + public PageRequest toPageRequest() { + var resolvedSort = Optional.of(SortUtils.resolve(sort)) + .filter(Sort::isUnsorted) + .orElse(defaultSort()); + return PageRequestImpl.of(pageNullSafe(getPage()), + sizeNullSafe(getSize()), resolvedSort); + } + } } diff --git a/application/src/test/java/run/halo/app/infra/utils/SortUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/SortUtilsTest.java new file mode 100644 index 000000000..d2960d35a --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/SortUtilsTest.java @@ -0,0 +1,37 @@ +package run.halo.app.infra.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link SortUtils}. + * + * @author guqing + * @since 2.19.0 + */ +class SortUtilsTest { + + @Test + void resolve() { + // null case + assertThat(SortUtils.resolve(null).isUnsorted()).isTrue(); + + // multiple sort and directions + var str = List.of("name,asc", "age,desc"); + var sort = SortUtils.resolve(str); + assertThat(sort.toString()).isEqualTo("name: ASC,age: DESC"); + + // missing direction + str = List.of("name"); + sort = SortUtils.resolve(str); + assertThat(sort.toString()).isEqualTo("name: ASC"); + + // whitespace in direction + assertThatThrownBy(() -> SortUtils.resolve(List.of("name, desc"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Direction must not contain whitespace"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplIntegrationTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplIntegrationTest.java new file mode 100644 index 000000000..1f95772d2 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplIntegrationTest.java @@ -0,0 +1,108 @@ +package run.halo.app.theme.finders.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.spring6.dialect.SpringStandardDialect; +import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator; +import org.thymeleaf.templateresolver.StringTemplateResolver; +import org.thymeleaf.templateresource.ITemplateResource; +import org.thymeleaf.templateresource.StringTemplateResource; +import reactor.core.publisher.Mono; +import run.halo.app.extension.ListResult; +import run.halo.app.theme.ReactiveSpelVariableExpressionEvaluator; +import run.halo.app.theme.finders.PostPublicQueryService; + +/** + * Tests for {@link PostFinderImpl}. + * + * @author guqing + * @since 2.19.0 + */ +@ExtendWith(MockitoExtension.class) +class PostFinderImplIntegrationTest { + + private TemplateEngine templateEngine; + + @Mock + private PostPublicQueryService postPublicQueryService; + + @InjectMocks + private PostFinderImpl postFinder; + + @Mock + private TemplateResourceComputer templateResourceComputer; + + @BeforeEach + void setUp() { + templateEngine = new SpringTemplateEngine(); + templateEngine.setDialect(new SpringStandardDialect() { + @Override + public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() { + return ReactiveSpelVariableExpressionEvaluator.INSTANCE; + } + }); + templateEngine.addTemplateResolver(new TestTemplateResolver(templateResourceComputer)); + } + + @Test + void listTest() { + var context = new Context(); + context.setVariable("postFinder", postFinder); + + // empty param + when(templateResourceComputer.compute(eq("post"))).thenReturn(new StringTemplateResource(""" + + """)); + + when(postPublicQueryService.list(any(), any())) + .thenReturn(Mono.just(ListResult.emptyResult())); + + var result = templateEngine.process("post", context); + assertThat(result).isEqualToIgnoringWhitespace( + "ListResult(page=0, size=0, total=0, items=[])"); + + when(templateResourceComputer.compute(eq("post"))).thenReturn(new StringTemplateResource(""" + + + """)); + result = templateEngine.process("post", context); + assertThat(result).isEqualToIgnoringWhitespace(""); + } + + static class TestTemplateResolver extends StringTemplateResolver { + private final TemplateResourceComputer templateResourceComputer; + + TestTemplateResolver(TemplateResourceComputer templateResourceComputer) { + this.templateResourceComputer = templateResourceComputer; + } + + @Override + protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, + String ownerTemplate, String template, + Map templateResolutionAttributes) { + return templateResourceComputer.compute(template); + } + } + + interface TemplateResourceComputer { + ITemplateResource compute(String template); + } +}