mirror of https://github.com/halo-dev/halo
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
parent
25893c0386
commit
ac0700e668
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue