feat: add unified parameter list method for post finder (#6531)

#### What type of PR is this?
/kind feature
/milestone 2.19.x
/area core

#### What this PR does / why we need it:
为 postFinder 添加一个统一参数的 list 方法并支持传递排序参数

Fixes https://github.com/halo-dev/halo/issues/4933

#### Does this PR introduce a user-facing change?
```release-note
为 postFinder 添加一个统一参数的 list 方法并支持传递排序参数
```
pull/6536/head
guqing 2024-08-27 18:17:18 +08:00 committed by GitHub
parent 25893c0386
commit ac0700e668
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 264 additions and 4 deletions

View File

@ -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 = ",";
/**
* <p>Resolve from direction params, e.g. "name,asc" or "name"</p>
*
* @param directionParams direction params
* @return sort object
*/
public static Sort resolve(List<String> 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);
}
}

View File

@ -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.
* <p>
* <p>Gets post detail by name.</p>
* We ensure the post is public, non-deleted and published.
*
* @param postName is post name
@ -35,6 +36,13 @@ public interface PostFinder {
Flux<ListedPostVo> listAll();
/**
* Lists posts by query params.
*
* @param params query params see {@link PostQuery}
*/
Mono<ListResult<ListedPostVo>> list(Map<String, Object> params);
Mono<ListResult<ListedPostVo>> list(@Nullable Integer page, @Nullable Integer size);
Mono<ListResult<ListedPostVo>> listByCategory(@Nullable Integer page, @Nullable Integer size,

View File

@ -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<ListResult<ListedPostVo>> list(Map<String, Object> 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<ListResult<ListedPostVo>> 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<String> 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);
}
}
}

View File

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

View File

@ -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("""
<span th:text="${postFinder.list({})}"></span>
"""));
when(postPublicQueryService.list(any(), any()))
.thenReturn(Mono.just(ListResult.emptyResult()));
var result = templateEngine.process("post", context);
assertThat(result).isEqualToIgnoringWhitespace(
"<span>ListResult(page=0, size=0, total=0, items=[])</span>");
when(templateResourceComputer.compute(eq("post"))).thenReturn(new StringTemplateResource("""
<span
th:each="post : ${postFinder.list({page: 1, size: 10, tagName: 'fake-tag',
ownerName: 'fake-owner', sort: {'spec.publishTime,desc',
'metadata.creationTimestamp,asc'}})}"
>
</span>
"""));
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<String, Object> templateResolutionAttributes) {
return templateResourceComputer.compute(template);
}
}
interface TemplateResourceComputer {
ITemplateResource compute(String template);
}
}