refactor: using indexes to query post lists (#5230)

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

#### What this PR does / why we need it:
使用索引功能来查询文章列表

how to test it?
1. 测试文章列表的筛选条件是否正确
2. 测试文章列表中关联的标签和分类信息是否正确
3. 测试仪表盘的文章数量统计是否正确
4. 测试分类关联文章的数量是否正确
5. 测试标签关联文章的文章是否正确
6. 测试主题端文章列表是否正确

#### Which issue(s) this PR fixes:
Fixes #5223

#### Does this PR introduce a user-facing change?
```release-note
使用高级索引功能检索文章以显著降低资源消耗并提供更快、更高效的文章检索体验
```
pull/5250/head v2.12.0-alpha.2
guqing 2024-01-25 12:17:12 +08:00 committed by GitHub
parent cecdb3f9ef
commit 3f27f6f262
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 781 additions and 1577 deletions

View File

@ -84,4 +84,7 @@ public class Snapshot extends AbstractExtension {
return Boolean.parseBoolean(annotations.get(Snapshot.KEEP_RAW_ANNO));
}
public static String toSubjectRefKey(Ref subjectRef) {
return subjectRef.getGroup() + "/" + subjectRef.getKind() + "/" + subjectRef.getName();
}
}

View File

@ -8,6 +8,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Stream;
import lombok.Data;
@ -144,6 +145,15 @@ public class ListResult<T> implements Iterable<T>, Supplier<Stream<T>> {
return listSort;
}
/**
* Gets the first element of the list result.
*/
public static <T> Optional<T> first(ListResult<T> listResult) {
return Optional.ofNullable(listResult)
.map(ListResult::getItems)
.map(list -> list.isEmpty() ? null : list.get(0));
}
@Override
public Stream<T> get() {
return items.stream();

View File

@ -1,10 +1,12 @@
package run.halo.app.extension.router.selector;
import java.util.Objects;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.index.query.QueryFactory;
public record FieldSelector(Query query) {
public record FieldSelector(@NonNull Query query) {
public FieldSelector(Query query) {
this.query = Objects.requireNonNullElseGet(query, QueryFactory::all);
}
@ -12,4 +14,13 @@ public record FieldSelector(Query query) {
public static FieldSelector of(Query query) {
return new FieldSelector(query);
}
public static FieldSelector all() {
return new FieldSelector(QueryFactory.all());
}
public FieldSelector andQuery(Query other) {
Assert.notNull(other, "Query must not be null");
return of(QueryFactory.and(query(), other));
}
}

View File

@ -24,6 +24,22 @@ public class LabelSelector implements Predicate<Map<String, String>> {
.allMatch(matcher -> matcher.test(labels.get(matcher.getKey())));
}
/**
* Returns a new label selector that is the result of ANDing the current selector with the
* given selector.
*
* @param other the selector to AND with
* @return a new label selector
*/
public LabelSelector and(LabelSelector other) {
var labelSelector = new LabelSelector();
var matchers = new ArrayList<SelectorMatcher>();
matchers.addAll(this.matchers);
matchers.addAll(other.matchers);
labelSelector.setMatchers(matchers);
return labelSelector;
}
public static LabelSelectorBuilder builder() {
return new LabelSelectorBuilder();
}

View File

@ -96,6 +96,8 @@ public final class SelectorUtil {
listOptions.setLabelSelector(new LabelSelector().setMatchers(labelMatchers));
if (!fieldQuery.isEmpty()) {
listOptions.setFieldSelector(FieldSelector.of(QueryFactory.and(fieldQuery)));
} else {
listOptions.setFieldSelector(FieldSelector.all());
}
return listOptions;
}

View File

@ -1,129 +0,0 @@
package run.halo.app.content;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.SetMultimap;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import run.halo.app.extension.Extension;
/**
* <p>A default implementation of {@link Indexer}.</p>
* <p>Note that this Indexer is not thread-safe, If multiple threads access this indexer
* concurrently and one of the threads modifies the indexer, it must be synchronized externally.</p>
*
* @param <T> the type of object to be indexed
* @author guqing
* @see
* <a href="https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/client-go/tools/cache/index.go">kubernetes index</a>
* @see <a href="https://juejin.cn/post/7132767272841510926">informercache.indexer</a>
* @since 2.0.0
*/
public class DefaultIndexer<T extends Extension> implements Indexer<T> {
private final Map<String, SetMultimap<String, String>> indices = new HashMap<>();
private final Map<String, IndexFunc<T>> indexFuncMap = new HashMap<>();
private final Map<String, SetMultimap<String, String>> indexValues = new HashMap<>();
@Override
public void addIndexFunc(String indexName, IndexFunc<T> indexFunc) {
indexFuncMap.put(indexName, indexFunc);
indices.put(indexName, HashMultimap.create());
indexValues.put(indexName, HashMultimap.create());
}
@Override
public Set<String> indexNames() {
return Set.copyOf(indexFuncMap.keySet());
}
@Override
public void add(String indexName, T obj) {
IndexFunc<T> indexFunc = getIndexFunc(indexName);
Set<String> indexKeys = indexFunc.apply(obj);
for (String indexKey : indexKeys) {
SetMultimap<String, String> index = indices.get(indexName);
index.put(indexKey, getObjectKey(obj));
SetMultimap<String, String> indexValue = indexValues.get(indexName);
indexValue.put(getObjectKey(obj), indexKey);
}
}
@NonNull
private IndexFunc<T> getIndexFunc(String indexName) {
IndexFunc<T> indexFunc = indexFuncMap.get(indexName);
if (indexFunc == null) {
throw new IllegalArgumentException(
"Index function not found for index name: " + indexName);
}
return indexFunc;
}
@Override
public void update(String indexName, T obj) {
IndexFunc<T> indexFunc = getIndexFunc(indexName);
Set<String> indexKeys = indexFunc.apply(obj);
Set<String> oldIndexKeys = new HashSet<>();
SetMultimap<String, String> indexValue = indexValues.get(indexName);
if (indexValue.containsKey(getObjectKey(obj))) {
oldIndexKeys.addAll(indexValue.get(getObjectKey(obj)));
}
// delete old index first
for (String oldIndexKey : oldIndexKeys) {
SetMultimap<String, String> index = indices.get(indexName);
index.remove(oldIndexKey, getObjectKey(obj));
indexValue.remove(getObjectKey(obj), oldIndexKey);
}
// add new index
for (String indexKey : indexKeys) {
SetMultimap<String, String> index = indices.get(indexName);
index.put(indexKey, getObjectKey(obj));
indexValue.put(getObjectKey(obj), indexKey);
}
}
@Override
public Set<String> getByIndex(String indexName, String indexKey) {
SetMultimap<String, String> index = indices.get(indexName);
if (index != null) {
return Set.copyOf(index.get(indexKey));
}
return Set.of();
}
@Override
public void delete(String indexName, T obj) {
IndexFunc<T> indexFunc = getIndexFunc(indexName);
SetMultimap<String, String> indexValue = indexValues.get(indexName);
Set<String> indexKeys = indexFunc.apply(obj);
for (String indexKey : indexKeys) {
String objectKey = getObjectKey(obj);
SetMultimap<String, String> index = indices.get(indexName);
index.remove(indexKey, objectKey);
indexValue.remove(indexKey, objectKey);
}
}
/**
* This method is only used for testing.
*
* @param indexName index name
* @return all indices of the given index name
*/
public Map<String, Collection<String>> getIndices(String indexName) {
return indices.get(indexName).asMap();
}
private String getObjectKey(T obj) {
Assert.notNull(obj, "Object must not be null");
Assert.notNull(obj.getMetadata(), "Object metadata must not be null");
Assert.notNull(obj.getMetadata().getName(), "Object name must not be null");
return obj.getMetadata().getName();
}
}

View File

@ -1,79 +0,0 @@
package run.halo.app.content;
import java.util.Set;
import run.halo.app.extension.Extension;
/**
* <p>Indexer is used to index objects by index name and index key.</p>
* <p>For example, if you want to index posts by category, you can use the following code:</p>
* <pre>
* Indexer&lt;Post&gt; indexer = new Indexer&lt;&gt;();
* indexer.addIndexFunc("category", post -&gt; {
* List&lt;String&gt; tags = post.getSpec().getTags();
* return tags == null ? Set.of() : Set.copyOf(tags);
* });
* indexer.add("category", post);
* indexer.getByIndex("category", "category-slug");
* indexer.update("category", post);
* indexer.delete("category", post);
* </pre>
*
* @param <T> the type of object to be indexed
* @author guqing
* @since 2.0.0
*/
public interface Indexer<T extends Extension> {
/**
* Adds an index function for a given index name.
*
* @param indexName The name of the index.
* @param indexFunc The function to use for indexing.
*/
void addIndexFunc(String indexName, DefaultIndexer.IndexFunc<T> indexFunc);
Set<String> indexNames();
/**
* The {@code add} method adds an object of type T to the index
* with the given name. It does this by first getting the index function for the given index
* name and applying it to the object to get a set of index keys. For each index key, it adds
* the object key to the index and the index key to the object's index values.
*
* <p>For example, if you want to index Person objects by name and age, you can use the
* following:</p>
* <pre>
* // Create an Indexer that indexes Person objects by name and age
* Indexer&lt;Person&gt; indexer = new Indexer<>();
* indexer.addIndexFunc("name", person -> Collections.singleton(person.getName()));
* indexer.addIndexFunc("age", person -> Collections.singleton(String.valueOf(person
* .getAge())));
*
* // Create some Person objects
* Person alice = new Person("Alice", 25);
* Person bob = new Person("Bob", 30);
*
* // Add the Person objects to the index
* indexer.add("name", alice);
* indexer.add("name", bob);
* indexer.add("age", alice);
* indexer.add("age", bob);
* </pre>
*
* @param indexName The name of the index.
* @param obj The function to use for indexing.
* @throws IllegalArgumentException if the index name is not found.
*/
void add(String indexName, T obj);
void update(String indexName, T obj);
Set<String> getByIndex(String indexName, String indexKey);
void delete(String indexName, T obj);
@FunctionalInterface
interface IndexFunc<T> {
Set<String> apply(T obj);
}
}

View File

@ -1,221 +0,0 @@
package run.halo.app.content;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.StampedLock;
import java.util.function.BiConsumer;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.DefaultExtensionMatcher;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.Watcher;
import run.halo.app.extension.controller.RequestSynchronizer;
/**
* <p>Monitor changes to {@link Post} resources and establish a local, in-memory cache in an
* Indexer.
* When changes to posts are detected, the Indexer is updated using the indexFunc to maintain
* its integrity.
* This enables quick retrieval of the unique identifier(It is usually {@link Metadata#getName()})
* for article objects using the getByIndex method when needed.</p>
*
* @author guqing
* @since 2.0.0
*/
@Component
public class PostIndexInformer implements ApplicationListener<ApplicationStartedEvent>,
DisposableBean {
public static final String TAG_POST_INDEXER = "tag-post-indexer";
public static final String LABEL_INDEXER_NAME = "post-label-indexer";
private final RequestSynchronizer synchronizer;
private final Indexer<Post> postIndexer;
private final PostWatcher postWatcher;
public PostIndexInformer(ExtensionClient client) {
postIndexer = new DefaultIndexer<>();
postIndexer.addIndexFunc(TAG_POST_INDEXER, post -> {
List<String> tags = post.getSpec().getTags();
return tags != null ? Set.copyOf(tags) : Set.of();
});
postIndexer.addIndexFunc(LABEL_INDEXER_NAME, labelIndexFunc());
this.postWatcher = new PostWatcher();
var emptyPost = new Post();
this.synchronizer = new RequestSynchronizer(true,
client,
emptyPost,
postWatcher,
DefaultExtensionMatcher.builder(client, emptyPost.groupVersionKind()).build()
);
}
private DefaultIndexer.IndexFunc<Post> labelIndexFunc() {
return post -> {
Map<String, String> labels = MetadataUtil.nullSafeLabels(post);
Set<String> indexKeys = new HashSet<>();
for (Map.Entry<String, String> entry : labels.entrySet()) {
indexKeys.add(labelKey(entry.getKey(), entry.getValue()));
}
return indexKeys;
};
}
public Set<String> getByTagName(String tagName) {
return postIndexer.getByIndex(TAG_POST_INDEXER, tagName);
}
public Set<String> getByLabels(Map<String, String> labels) {
if (labels == null) {
return Set.of();
}
Set<String> result = new HashSet<>();
for (Map.Entry<String, String> entry : labels.entrySet()) {
Set<String> values = postIndexer.getByIndex(LABEL_INDEXER_NAME,
labelKey(entry.getKey(), entry.getValue()));
if (values == null) {
// No objects have this label, no need to continue searching
return Set.of();
}
if (result.isEmpty()) {
result.addAll(values);
} else {
result.retainAll(values);
}
}
return result;
}
String labelKey(String labelName, String labelValue) {
return labelName + "=" + labelValue;
}
@Override
public void destroy() throws Exception {
if (postWatcher != null) {
postWatcher.dispose();
}
if (synchronizer != null) {
synchronizer.dispose();
}
}
@Override
public void onApplicationEvent(@NonNull ApplicationStartedEvent event) {
if (!synchronizer.isStarted()) {
synchronizer.start();
}
}
class PostWatcher implements Watcher {
private Runnable disposeHook;
private boolean disposed = false;
private final StampedLock lock = new StampedLock();
@Override
public void onAdd(Extension extension) {
if (!checkExtension(extension)) {
return;
}
handleIndicates(extension, postIndexer::add);
}
@Override
public void onUpdate(Extension oldExt, Extension newExt) {
if (!checkExtension(newExt)) {
return;
}
handleIndicates(newExt, postIndexer::update);
}
@Override
public void onDelete(Extension extension) {
if (!checkExtension(extension)) {
return;
}
handleIndicates(extension, postIndexer::delete);
}
@Override
public void registerDisposeHook(Runnable dispose) {
this.disposeHook = dispose;
}
@Override
public void dispose() {
if (isDisposed()) {
return;
}
this.disposed = true;
if (this.disposeHook != null) {
this.disposeHook.run();
}
}
@Override
public boolean isDisposed() {
return this.disposed;
}
void handleIndicates(Extension extension, BiConsumer<String, Post> consumer) {
Post post = convertTo(extension);
Set<String> indexNames = getIndexNames();
for (String indexName : indexNames) {
maintainIndicates(indexName, post, consumer);
}
}
Set<String> getIndexNames() {
long stamp = lock.tryOptimisticRead();
Set<String> indexNames = postIndexer.indexNames();
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
return postIndexer.indexNames();
} finally {
lock.unlockRead(stamp);
}
}
return indexNames;
}
void maintainIndicates(String indexName, Post post, BiConsumer<String, Post> consumer) {
long stamp = lock.writeLock();
try {
consumer.accept(indexName, post);
} finally {
lock.unlockWrite(stamp);
}
}
}
private Post convertTo(Extension extension) {
if (extension instanceof Post) {
return (Post) extension;
}
return Unstructured.OBJECT_MAPPER.convertValue(extension, Post.class);
}
private boolean checkExtension(Extension extension) {
return !postWatcher.isDisposed()
&& extension.getMetadata().getDeletionTimestamp() == null
&& isPost(extension);
}
private boolean isPost(Extension extension) {
return GroupVersionKind.fromExtension(Post.class).equals(extension.groupVersionKind());
}
}

View File

@ -1,28 +1,23 @@
package run.halo.app.content;
import static java.util.Comparator.comparing;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.endpoint.SortResolver;
import run.halo.app.extension.Comparators;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
/**
* A query object for {@link Post} list.
@ -52,36 +47,12 @@ public class PostQuery extends IListRequest.QueryListRequest {
return username;
}
@Nullable
@Schema(name = "contributor")
public Set<String> getContributors() {
return listToSet(queryParams.get("contributor"));
}
@Nullable
@Schema(name = "category")
public Set<String> getCategories() {
return listToSet(queryParams.get("category"));
}
@Nullable
@Schema(name = "tag")
public Set<String> getTags() {
return listToSet(queryParams.get("tag"));
}
@Nullable
public Post.PostPhase getPublishPhase() {
String publishPhase = queryParams.getFirst("publishPhase");
return Post.PostPhase.from(publishPhase);
}
@Nullable
public Post.VisibleEnum getVisible() {
String visible = queryParams.getFirst("visible");
return Post.VisibleEnum.from(visible);
}
@Nullable
@Schema(description = "Posts filtered by keyword.")
public String getKeyword() {
@ -96,127 +67,58 @@ public class PostQuery extends IListRequest.QueryListRequest {
implementation = String.class,
example = "creationTimestamp,desc"))
public Sort getSort() {
return SortResolver.defaultInstance.resolve(exchange);
}
@Nullable
private Set<String> listToSet(List<String> param) {
return param == null ? null : Set.copyOf(param);
var sort = SortResolver.defaultInstance.resolve(exchange);
sort = sort.and(Sort.by(Sort.Direction.DESC, "metadata.creationTimestamp"));
sort = sort.and(Sort.by(Sort.Direction.DESC, "metadata.name"));
return sort;
}
/**
* Build a comparator from the query object.
* Build a list options from the query object.
*
* @return a comparator
* @return a list options
*/
public Comparator<Post> toComparator() {
var sort = getSort();
var creationTimestampOrder = sort.getOrderFor("creationTimestamp");
List<Comparator<Post>> comparators = new ArrayList<>();
if (creationTimestampOrder != null) {
Comparator<Post> comparator =
comparing(post -> post.getMetadata().getCreationTimestamp());
if (creationTimestampOrder.isDescending()) {
comparator = comparator.reversed();
}
comparators.add(comparator);
}
var publishTimeOrder = sort.getOrderFor("publishTime");
if (publishTimeOrder != null) {
Comparator<Object> nullsComparator = publishTimeOrder.isAscending()
? org.springframework.util.comparator.Comparators.nullsLow()
: org.springframework.util.comparator.Comparators.nullsHigh();
Comparator<Post> comparator =
comparing(post -> post.getSpec().getPublishTime(), nullsComparator);
if (publishTimeOrder.isDescending()) {
comparator = comparator.reversed();
}
comparators.add(comparator);
}
comparators.add(Comparators.compareCreationTimestamp(false));
comparators.add(Comparators.compareName(true));
return comparators.stream()
.reduce(Comparator::thenComparing)
.orElse(null);
}
/**
* Build a predicate from the query object.
*
* @return a predicate
*/
public Predicate<Post> toPredicate() {
Predicate<Post> predicate = labelAndFieldSelectorToPredicate(getLabelSelector(),
getFieldSelector());
if (!CollectionUtils.isEmpty(getCategories())) {
predicate =
predicate.and(post -> contains(getCategories(), post.getSpec().getCategories()));
}
if (!CollectionUtils.isEmpty(getTags())) {
predicate = predicate.and(post -> contains(getTags(), post.getSpec().getTags()));
}
if (!CollectionUtils.isEmpty(getContributors())) {
Predicate<Post> hasStatus = post -> post.getStatus() != null;
var containsContributors = hasStatus.and(
post -> contains(getContributors(), post.getStatus().getContributors())
);
predicate = predicate.and(containsContributors);
public ListOptions toListOptions() {
var listOptions =
labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector());
if (listOptions.getFieldSelector() == null) {
listOptions.setFieldSelector(FieldSelector.all());
}
var labelSelectorBuilder = LabelSelector.builder();
var fieldQuery = QueryFactory.all();
String keyword = getKeyword();
if (keyword != null) {
predicate = predicate.and(post -> {
String excerpt = post.getStatusOrDefault().getExcerpt();
return StringUtils.containsIgnoreCase(excerpt, keyword)
|| StringUtils.containsIgnoreCase(post.getSpec().getSlug(), keyword)
|| StringUtils.containsIgnoreCase(post.getSpec().getTitle(), keyword);
});
fieldQuery = QueryFactory.and(fieldQuery, QueryFactory.or(
QueryFactory.contains("status.excerpt", keyword),
QueryFactory.contains("spec.slug", keyword),
QueryFactory.contains("spec.title", keyword)
));
}
Post.PostPhase publishPhase = getPublishPhase();
if (publishPhase != null) {
predicate = predicate.and(post -> {
if (Post.PostPhase.PENDING_APPROVAL.equals(publishPhase)) {
return !post.isPublished()
&& Post.PostPhase.PENDING_APPROVAL.name()
.equalsIgnoreCase(post.getStatusOrDefault().getPhase());
}
// published
if (Post.PostPhase.PUBLISHED.equals(publishPhase)) {
return post.isPublished();
}
// draft
return !post.isPublished();
});
}
Post.VisibleEnum visible = getVisible();
if (visible != null) {
predicate =
predicate.and(post -> visible.equals(post.getSpec().getVisible()));
if (Post.PostPhase.PENDING_APPROVAL.equals(publishPhase)) {
fieldQuery = QueryFactory.and(fieldQuery, QueryFactory.equal(
"status.phase", Post.PostPhase.PENDING_APPROVAL.name())
);
labelSelectorBuilder.eq(Post.PUBLISHED_LABEL, BooleanUtils.FALSE);
} else if (Post.PostPhase.PUBLISHED.equals(publishPhase)) {
labelSelectorBuilder.eq(Post.PUBLISHED_LABEL, BooleanUtils.TRUE);
} else {
labelSelectorBuilder.eq(Post.PUBLISHED_LABEL, BooleanUtils.FALSE);
}
}
if (StringUtils.isNotBlank(username)) {
Predicate<Post> isOwner = post -> Objects.equals(username, post.getSpec().getOwner());
predicate = predicate.and(isOwner);
fieldQuery = QueryFactory.and(fieldQuery, QueryFactory.equal(
"spec.owner", username)
);
}
return predicate;
}
boolean contains(Collection<String> left, List<String> right) {
// parameter is null, it means that ignore this condition
if (left == null) {
return true;
}
// else, it means that right is empty
if (left.isEmpty()) {
return right.isEmpty();
}
if (right == null) {
return false;
}
return right.stream().anyMatch(left::contains);
listOptions.setFieldSelector(listOptions.getFieldSelector().andQuery(fieldQuery));
listOptions.setLabelSelector(
listOptions.getLabelSelector().and(labelSelectorBuilder.build()));
return listOptions;
}
}

View File

@ -1,5 +1,7 @@
package run.halo.app.content.impl;
import static run.halo.app.extension.index.query.QueryFactory.in;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@ -8,6 +10,7 @@ import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@ -27,9 +30,12 @@ import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.metrics.CounterService;
@ -58,16 +64,17 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
@Override
public Mono<ListResult<ListedPost>> listPost(PostQuery query) {
return client.list(Post.class, query.toPredicate(),
query.toComparator(), query.getPage(), query.getSize())
.flatMap(listResult -> Flux.fromStream(
listResult.get().map(this::getListedPost)
)
.concatMap(Function.identity())
.collectList()
.map(listedPosts -> new ListResult<>(listResult.getPage(), listResult.getSize(),
listResult.getTotal(), listedPosts)
)
return client.listBy(Post.class, query.toListOptions(),
PageRequestImpl.of(query.getPage(), query.getSize(), query.getSort())
)
.flatMap(listResult -> Flux.fromStream(listResult.get())
.map(this::getListedPost)
.concatMap(Function.identity())
.collectList()
.map(listedPosts -> new ListResult<>(listResult.getPage(), listResult.getSize(),
listResult.getTotal(), listedPosts)
)
.defaultIfEmpty(ListResult.emptyResult())
);
}
@ -144,16 +151,18 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
if (tagNames == null) {
return Flux.empty();
}
return Flux.fromIterable(tagNames)
.flatMapSequential(tagName -> client.fetch(Tag.class, tagName));
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(in("metadata.name", tagNames)));
return client.listAll(Tag.class, listOptions, Sort.by("metadata.creationTimestamp"));
}
private Flux<Category> listCategories(List<String> categoryNames) {
if (categoryNames == null) {
return Flux.empty();
}
return Flux.fromIterable(categoryNames)
.flatMapSequential(categoryName -> client.fetch(Category.class, categoryName));
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(in("metadata.name", categoryNames)));
return client.listAll(Category.class, listOptions, Sort.by("metadata.creationTimestamp"));
}
private Flux<Contributor> listContributors(List<String> usernames) {

View File

@ -19,7 +19,7 @@ public class SnapshotServiceImpl implements SnapshotService {
private final ReactiveExtensionClient client;
private Clock clock;
private final Clock clock;
public SnapshotServiceImpl(ReactiveExtensionClient client) {
this.client = client;

View File

@ -2,6 +2,9 @@ package run.halo.app.core.extension.endpoint;
import static java.lang.Boolean.parseBoolean;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import lombok.Data;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
@ -13,8 +16,11 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Counter;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.selector.FieldSelector;
/**
* Stats endpoint.
@ -67,13 +73,16 @@ public class StatsEndpoint implements CustomEndpoint {
stats.setUsers(count.intValue());
return stats;
}))
.flatMap(stats -> client.list(Post.class, post -> !post.isDeleted(), null)
.count()
.map(count -> {
stats.setPosts(count.intValue());
return stats;
})
)
.flatMap(stats -> {
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(
and(isNull("metadata.deletionTimestamp"),
equal("spec.deleted", "false")))
);
return client.listBy(Post.class, listOptions, PageRequestImpl.ofSize(1))
.doOnNext(list -> stats.setPosts((int) list.getTotal()))
.thenReturn(stats);
})
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
}

View File

@ -1,5 +1,9 @@
package run.halo.app.core.extension.reconciler;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
@ -13,6 +17,7 @@ import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
@ -20,10 +25,12 @@ import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.utils.JsonUtils;
/**
@ -138,7 +145,12 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
.map(item -> item.getMetadata().getName())
.toList();
List<Post> posts = client.list(Post.class, post -> !post.isDeleted(), null);
var postListOptions = new ListOptions();
postListOptions.setFieldSelector(FieldSelector.of(
and(isNull("metadata.deletionTimestamp"),
equal("spec.deleted", "false")))
);
var posts = client.listAll(Post.class, postListOptions, Sort.unsorted());
// populate post to status
List<Post.CompactPost> compactPosts = posts.stream()
@ -178,7 +190,7 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
}
private List<Category> listChildrenByName(String name) {
List<Category> categories = client.list(Category.class, null, null);
var categories = client.listAll(Category.class, new ListOptions(), Sort.unsorted());
Map<String, Category> nameIdentityMap = categories.stream()
.collect(Collectors.toMap(category -> category.getMetadata().getName(),
Function.identity()));

View File

@ -9,6 +9,7 @@ import com.google.common.hash.Hashing;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@ -18,6 +19,7 @@ import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.NotificationReasonConst;
@ -36,10 +38,13 @@ import run.halo.app.event.post.PostUpdatedEvent;
import run.halo.app.event.post.PostVisibleChangedEvent;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Ref;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.HaloUtils;
@ -189,8 +194,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
var ref = Ref.of(post);
// handle contributors
var headSnapshot = post.getSpec().getHeadSnapshot();
var contributors = client.list(Snapshot.class,
snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null)
var contributors = listSnapshots(ref)
.stream()
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
@ -292,7 +296,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
}
var labels = post.getMetadata().getLabels();
labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString());
var status = post.getStatus();
final var status = post.getStatus();
var condition = new Condition();
condition.setType("CancelledPublish");
@ -310,9 +314,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
private void cleanUpResources(Post post) {
// clean up snapshots
final Ref ref = Ref.of(post);
client.list(Snapshot.class,
snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null)
.forEach(client::delete);
listSnapshots(ref).forEach(client::delete);
// clean up comments
client.list(Comment.class, comment -> ref.equals(comment.getSpec().getSubjectRef()),
@ -330,4 +332,11 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
// TODO The default capture 150 words as excerpt
return StringUtils.substring(text, 0, 150);
}
List<Snapshot> listSnapshots(Ref ref) {
var snapshotListOptions = new ListOptions();
snapshotListOptions.setFieldSelector(FieldSelector.of(
QueryFactory.equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref))));
return client.listAll(Snapshot.class, snapshotListOptions, Sort.unsorted());
}
}

View File

@ -14,6 +14,7 @@ import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import run.halo.app.content.NotificationReasonConst;
@ -26,11 +27,14 @@ import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.Ref;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionList;
import run.halo.app.infra.ConditionStatus;
@ -243,9 +247,7 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
private void cleanUpResources(SinglePage singlePage) {
// clean up snapshot
Ref ref = Ref.of(singlePage);
client.list(Snapshot.class,
snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null)
.forEach(client::delete);
listSnapshots(ref).forEach(client::delete);
// clean up comments
client.list(Comment.class, comment -> comment.getSpec().getSubjectRef().equals(ref),
@ -332,8 +334,7 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
// handle contributors
String headSnapshot = singlePage.getSpec().getHeadSnapshot();
List<String> contributors = client.list(Snapshot.class,
snapshot -> Ref.of(singlePage).equals(snapshot.getSpec().getSubjectRef()), null)
List<String> contributors = listSnapshots(Ref.of(singlePage))
.stream()
.peek(snapshot -> {
snapshot.getSpec().setContentPatch(StringUtils.EMPTY);
@ -377,4 +378,11 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
return Objects.equals(true, singlePage.getSpec().getDeleted())
|| singlePage.getMetadata().getDeletionTimestamp() != null;
}
List<Snapshot> listSnapshots(Ref ref) {
var snapshotListOptions = new ListOptions();
snapshotListOptions.setFieldSelector(FieldSelector.of(
QueryFactory.equal("spec.subjectRef", Snapshot.toSubjectRefKey(ref))));
return client.listAll(Snapshot.class, snapshotListOptions, Sort.unsorted());
}
}

View File

@ -1,5 +1,9 @@
package run.halo.app.core.extension.reconciler;
import static org.apache.commons.lang3.BooleanUtils.isFalse;
import static run.halo.app.extension.MetadataUtil.nullSafeLabels;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.time.Duration;
import java.util.HashSet;
import java.util.Map;
@ -7,17 +11,19 @@ import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import run.halo.app.content.PostIndexInformer;
import run.halo.app.content.permalinks.TagPermalinkPolicy;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.utils.JsonUtils;
/**
@ -32,7 +38,6 @@ public class TagReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER_NAME = "tag-protection";
private final ExtensionClient client;
private final TagPermalinkPolicy tagPermalinkPolicy;
private final PostIndexInformer postIndexInformer;
@Override
public Result reconcile(Request request) {
@ -128,20 +133,22 @@ public class TagReconciler implements Reconciler<Reconciler.Request> {
}
private void populatePosts(Tag tag) {
// populate post count
Set<String> postNames = postIndexInformer.getByTagName(tag.getMetadata().getName());
tag.getStatusOrDefault().setPostCount(postNames.size());
// populate post-count
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(
equal("spec.tags", tag.getMetadata().getName()))
);
var posts = client.listAll(Post.class, listOptions, Sort.unsorted());
tag.getStatusOrDefault().setPostCount(posts.size());
// populate visible post count
Map<String, String> labelToQuery = Map.of(Post.PUBLISHED_LABEL, BooleanUtils.TRUE,
Post.VISIBLE_LABEL, Post.VisibleEnum.PUBLIC.name(),
Post.DELETED_LABEL, BooleanUtils.FALSE);
Set<String> hasAllLabelPosts = postIndexInformer.getByLabels(labelToQuery);
// retain all posts that has all labels
Set<String> postNamesWithTag = new HashSet<>(postNames);
postNamesWithTag.retainAll(hasAllLabelPosts);
tag.getStatusOrDefault().setVisiblePostCount(postNamesWithTag.size());
var publicPosts = posts.stream()
.filter(post -> post.getMetadata().getDeletionTimestamp() == null
&& isFalse(post.getSpec().getDeleted())
&& BooleanUtils.TRUE.equals(nullSafeLabels(post).get(Post.PUBLISHED_LABEL))
&& Post.VisibleEnum.PUBLIC.equals(post.getSpec().getVisible())
)
.toList();
tag.getStatusOrDefault().setVisiblePostCount(publicPosts.size());
}
private boolean isDeleted(Tag tag) {

View File

@ -1,5 +1,10 @@
package run.halo.app.infra;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.extension.index.IndexAttributeFactory.multiValueAttribute;
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
import java.util.Set;
import org.springframework.boot.context.event.ApplicationContextInitializedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
@ -38,6 +43,7 @@ import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.DefaultSchemeWatcherManager;
import run.halo.app.extension.Secret;
import run.halo.app.extension.index.IndexSpec;
import run.halo.app.extension.index.IndexSpecRegistryImpl;
import run.halo.app.migration.Backup;
import run.halo.app.plugin.extensionpoint.ExtensionDefinition;
@ -70,10 +76,102 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
schemeManager.register(Theme.class);
schemeManager.register(Menu.class);
schemeManager.register(MenuItem.class);
schemeManager.register(Post.class);
schemeManager.register(Category.class);
schemeManager.register(Tag.class);
schemeManager.register(Snapshot.class);
schemeManager.register(Post.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.title")
.setIndexFunc(simpleAttribute(Post.class, post -> post.getSpec().getTitle())));
indexSpecs.add(new IndexSpec()
.setName("spec.slug")
// Compatible with old data, hoping to set it to true in the future
.setUnique(false)
.setIndexFunc(simpleAttribute(Post.class, post -> post.getSpec().getSlug())));
indexSpecs.add(new IndexSpec()
.setName("spec.publishTime")
.setIndexFunc(simpleAttribute(Post.class, post -> {
var publishTime = post.getSpec().getPublishTime();
return publishTime == null ? null : publishTime.toString();
})));
indexSpecs.add(new IndexSpec()
.setName("spec.owner")
.setIndexFunc(simpleAttribute(Post.class, post -> post.getSpec().getOwner())));
indexSpecs.add(new IndexSpec()
.setName("spec.deleted")
.setIndexFunc(simpleAttribute(Post.class, post -> {
var deleted = post.getSpec().getDeleted();
return deleted == null ? "false" : deleted.toString();
})));
indexSpecs.add(new IndexSpec()
.setName("spec.pinned")
.setIndexFunc(simpleAttribute(Post.class, post -> {
var pinned = post.getSpec().getPinned();
return pinned == null ? "false" : pinned.toString();
})));
indexSpecs.add(new IndexSpec()
.setName("spec.priority")
.setIndexFunc(simpleAttribute(Post.class, post -> {
var priority = post.getSpec().getPriority();
return priority == null ? "0" : priority.toString();
})));
indexSpecs.add(new IndexSpec()
.setName("spec.visible")
.setIndexFunc(
simpleAttribute(Post.class, post -> post.getSpec().getVisible().name())));
indexSpecs.add(new IndexSpec()
.setName("spec.tags")
.setIndexFunc(multiValueAttribute(Post.class, post -> {
var tags = post.getSpec().getTags();
return tags == null ? Set.of() : Set.copyOf(tags);
})));
indexSpecs.add(new IndexSpec()
.setName("spec.categories")
.setIndexFunc(multiValueAttribute(Post.class, post -> {
var categories = post.getSpec().getCategories();
return categories == null ? Set.of() : Set.copyOf(categories);
})));
indexSpecs.add(new IndexSpec()
.setName("status.contributors")
.setIndexFunc(multiValueAttribute(Post.class, post -> {
var contributors = post.getStatusOrDefault().getContributors();
return contributors == null ? Set.of() : Set.copyOf(contributors);
})));
indexSpecs.add(new IndexSpec()
.setName("status.categories")
.setIndexFunc(
simpleAttribute(Post.class, post -> post.getStatusOrDefault().getExcerpt())));
indexSpecs.add(new IndexSpec()
.setName("status.phase")
.setIndexFunc(
simpleAttribute(Post.class, post -> post.getStatusOrDefault().getPhase())));
indexSpecs.add(new IndexSpec()
.setName("status.excerpt")
.setIndexFunc(
simpleAttribute(Post.class, post -> post.getStatusOrDefault().getExcerpt())));
});
schemeManager.register(Category.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.slug")
.setIndexFunc(
simpleAttribute(Category.class, category -> category.getSpec().getSlug()))
);
indexSpecs.add(new IndexSpec()
.setName("spec.priority")
.setIndexFunc(simpleAttribute(Category.class,
category -> defaultIfNull(category.getSpec().getPriority(), 0).toString())));
});
schemeManager.register(Tag.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.slug")
.setIndexFunc(simpleAttribute(Tag.class, tag -> tag.getSpec().getSlug()))
);
});
schemeManager.register(Snapshot.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.subjectRef")
.setIndexFunc(simpleAttribute(Snapshot.class,
snapshot -> Snapshot.toSubjectRefKey(snapshot.getSpec().getSubjectRef()))
)
);
});
schemeManager.register(Comment.class);
schemeManager.register(Reply.class);
schemeManager.register(SinglePage.class);

View File

@ -2,11 +2,9 @@ package run.halo.app.theme.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static run.halo.app.theme.endpoint.PublicApiUtils.containsElement;
import static run.halo.app.theme.endpoint.PublicApiUtils.toAnotherListResult;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.MediaType;
@ -17,11 +15,11 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.QueryParamBuildUtil;
import run.halo.app.extension.router.SortableRequest;
import run.halo.app.theme.finders.PostPublicQueryService;
@ -93,13 +91,11 @@ public class CategoryQueryEndpoint implements CustomEndpoint {
private Mono<ServerResponse> listPostsByCategoryName(ServerRequest request) {
final var name = request.pathVariable("name");
final var query = new PostPublicQuery(request.exchange());
Predicate<Post> categoryContainsPredicate =
post -> containsElement(post.getSpec().getCategories(), name);
return postPublicQueryService.list(query.getPage(),
query.getSize(),
categoryContainsPredicate.and(query.toPredicate()),
query.toComparator()
)
var listOptions = query.toListOptions();
var newFieldSelector = listOptions.getFieldSelector()
.andQuery(QueryFactory.equal("spec.categories", name));
listOptions.setFieldSelector(newFieldSelector);
return postPublicQueryService.list(listOptions, query.toPageRequest())
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result)
@ -118,12 +114,7 @@ public class CategoryQueryEndpoint implements CustomEndpoint {
private Mono<ServerResponse> listCategories(ServerRequest request) {
CategoryPublicQuery query = new CategoryPublicQuery(request.exchange());
return client.list(Category.class,
query.toPredicate(),
query.toComparator(),
query.getPage(),
query.getSize()
)
return client.listBy(Category.class, query.toListOptions(), query.toPageRequest())
.map(listResult -> toAnotherListResult(listResult, CategoryVo::from))
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)

View File

@ -107,8 +107,7 @@ public class PostQueryEndpoint implements CustomEndpoint {
private Mono<ServerResponse> listPosts(ServerRequest request) {
PostPublicQuery query = new PostPublicQuery(request.exchange());
return postPublicQueryService.list(query.getPage(), query.getSize(), query.toPredicate(),
query.toComparator())
return postPublicQueryService.list(query.toListOptions(), query.toPageRequest())
.flatMap(result -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON)
.bodyValue(result)
);

View File

@ -2,10 +2,8 @@ package run.halo.app.theme.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static run.halo.app.theme.endpoint.PublicApiUtils.containsElement;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.MediaType;
@ -15,11 +13,12 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.QueryParamBuildUtil;
import run.halo.app.extension.router.SortableRequest;
import run.halo.app.theme.finders.PostPublicQueryService;
@ -37,6 +36,7 @@ import run.halo.app.theme.finders.vo.TagVo;
@RequiredArgsConstructor
public class TagQueryEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client;
private final TagFinder tagFinder;
private final PostPublicQueryService postPublicQueryService;
@ -102,13 +102,11 @@ public class TagQueryEndpoint implements CustomEndpoint {
private Mono<ServerResponse> listPostsByTagName(ServerRequest request) {
final var name = request.pathVariable("name");
final var query = new PostPublicQuery(request.exchange());
final Predicate<Post> containsTagPredicate =
post -> containsElement(post.getSpec().getTags(), name);
return postPublicQueryService.list(query.getPage(),
query.getSize(),
containsTagPredicate.and(query.toPredicate()),
query.toComparator()
)
var listOptions = query.toListOptions();
var newFieldSelector = listOptions.getFieldSelector()
.andQuery(QueryFactory.equal("spec.tags", name));
listOptions.setFieldSelector(newFieldSelector);
return postPublicQueryService.list(listOptions, query.toPageRequest())
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result)
@ -117,11 +115,12 @@ public class TagQueryEndpoint implements CustomEndpoint {
private Mono<ServerResponse> listTags(ServerRequest request) {
var query = new TagPublicQuery(request.exchange());
return tagFinder.list(query.getPage(),
query.getSize(),
query.toPredicate(),
query.toComparator()
)
return client.listBy(Tag.class, query.toListOptions(), query.toPageRequest())
.map(result -> {
var tagVos = tagFinder.convertToVo(result.getItems());
return new ListResult<>(result.getPage(), result.getSize(),
result.getTotal(), tagVos);
})
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result)

View File

@ -1,11 +1,11 @@
package run.halo.app.theme.finders;
import java.util.Comparator;
import java.util.function.Predicate;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
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.theme.ReactivePostContentHandler;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
@ -14,17 +14,13 @@ import run.halo.app.theme.finders.vo.PostVo;
public interface PostPublicQueryService {
/**
* Lists posts page by predicate and comparator.
* Lists public posts by the given list options and page request.
*
* @param page page number
* @param size page size
* @param postPredicate post predicate
* @param comparator post comparator
* @return list result
* @param listOptions additional list options
* @param page page request must not be null
* @return a list of listed post vo
*/
Mono<ListResult<ListedPostVo>> list(Integer page, Integer size,
Predicate<Post> postPredicate,
Comparator<Post> comparator);
Mono<ListResult<ListedPostVo>> list(ListOptions listOptions, PageRequest page);
/**
* Converts post to listed post vo.

View File

@ -24,8 +24,11 @@ public interface TagFinder {
Mono<ListResult<TagVo>> list(@Nullable Integer page, @Nullable Integer size);
@Deprecated(since = "2.12.0")
Mono<ListResult<TagVo>> list(@Nullable Integer page, @Nullable Integer size,
@Nullable Predicate<Tag> predicate, @Nullable Comparator<Tag> comparator);
List<TagVo> convertToVo(List<Tag> tags);
Flux<TagVo> listAll();
}

View File

@ -11,11 +11,16 @@ import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.theme.finders.CategoryFinder;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.vo.CategoryTreeVo;
@ -51,10 +56,17 @@ public class CategoryFinderImpl implements CategoryFinder {
.flatMap(this::getByName);
}
static Sort defaultSort() {
return Sort.by(Sort.Order.desc("spec.priority"),
Sort.Order.desc("metadata.creationTimestamp"),
Sort.Order.desc("metadata.name"));
}
@Override
public Mono<ListResult<CategoryVo>> list(Integer page, Integer size) {
return client.list(Category.class, null,
defaultComparator(), pageNullSafe(page), sizeNullSafe(size))
return client.listBy(Category.class, new ListOptions(),
PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultSort())
)
.map(list -> {
List<CategoryVo> categoryVos = list.get()
.map(CategoryVo::from)
@ -65,12 +77,6 @@ public class CategoryFinderImpl implements CategoryFinder {
.defaultIfEmpty(new ListResult<>(page, size, 0L, List.of()));
}
@Override
public Flux<CategoryVo> listAll() {
return client.list(Category.class, null, defaultComparator())
.map(CategoryVo::from);
}
@Override
public Flux<CategoryTreeVo> listAsTree() {
return this.toCategoryTreeVoFlux(null);
@ -82,20 +88,9 @@ public class CategoryFinderImpl implements CategoryFinder {
}
@Override
public Mono<CategoryVo> getParentByName(String name) {
if (StringUtils.isBlank(name)) {
return Mono.empty();
}
return client.list(Category.class,
category -> {
List<String> children = category.getSpec().getChildren();
if (children == null) {
return false;
}
return children.contains(name);
},
defaultComparator())
.next().map(CategoryVo::from);
public Flux<CategoryVo> listAll() {
return client.listAll(Category.class, new ListOptions(), defaultSort())
.map(CategoryVo::from);
}
Flux<CategoryTreeVo> toCategoryTreeVoFlux(String name) {
@ -169,6 +164,22 @@ public class CategoryFinderImpl implements CategoryFinder {
.reversed();
}
@Override
public Mono<CategoryVo> getParentByName(String name) {
if (StringUtils.isBlank(name)) {
return Mono.empty();
}
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(
QueryFactory.equal("spec.children", name)
));
return client.listBy(Category.class, listOptions,
PageRequestImpl.of(1, 1, defaultSort())
)
.map(ListResult::first)
.mapNotNull(item -> item.map(CategoryVo::from).orElse(null));
}
int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}

View File

@ -1,25 +1,26 @@
package run.halo.app.theme.finders.impl;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.util.comparator.Comparators;
import org.springframework.data.domain.Sort;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
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.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
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.theme.finders.Finder;
import run.halo.app.theme.finders.PostFinder;
@ -64,33 +65,32 @@ public class PostFinderImpl implements PostFinder {
return postPublicQueryService.getContent(postName);
}
@Override
public Mono<NavigationPostVo> cursor(String currentName) {
// TODO Optimize the post names query here
return postPredicateResolver.getPredicate()
.flatMapMany(postPredicate ->
client.list(Post.class, postPredicate, defaultComparator())
)
.map(post -> post.getMetadata().getName())
.collectList()
.flatMap(postNames -> Mono.just(NavigationPostVo.builder())
.flatMap(builder -> getByName(currentName)
.doOnNext(builder::current)
.thenReturn(builder)
)
.flatMap(builder -> {
Pair<String, String> previousNextPair =
postPreviousNextPair(postNames, currentName);
String previousPostName = previousNextPair.getLeft();
String nextPostName = previousNextPair.getRight();
return fetchByName(previousPostName)
.doOnNext(builder::previous)
.then(fetchByName(nextPostName))
.doOnNext(builder::next)
.thenReturn(builder);
})
.map(NavigationPostVo.NavigationPostVoBuilder::build))
.defaultIfEmpty(NavigationPostVo.empty());
static Sort defaultSort() {
return Sort.by(Sort.Order.desc("spec.pinned"),
Sort.Order.desc("spec.priority"),
Sort.Order.desc("spec.publishTime"),
Sort.Order.desc("metadata.name")
);
}
@NonNull
static LinkNavigation findPostNavigation(List<String> postNames, String target) {
Assert.notNull(target, "Target post name must not be null");
for (int i = 0; i < postNames.size(); i++) {
var item = postNames.get(i);
if (target.equals(item)) {
var prevLink = (i > 0) ? postNames.get(i - 1) : null;
var nextLink = (i < postNames.size() - 1) ? postNames.get(i + 1) : null;
return new LinkNavigation(prevLink, target, nextLink);
}
}
return new LinkNavigation(null, target, null);
}
static Sort archiveSort() {
return Sort.by(Sort.Order.desc("spec.publishTime"),
Sort.Order.desc("metadata.name")
);
}
private Mono<PostVo> fetchByName(String name) {
@ -102,106 +102,76 @@ public class PostFinderImpl implements PostFinder {
}
@Override
public Flux<ListedPostVo> listAll() {
return postPredicateResolver.getPredicate()
.flatMapMany(predicate -> client.list(Post.class, predicate, defaultComparator()))
.concatMap(postPublicQueryService::convertToListedVo);
}
static Pair<String, String> postPreviousNextPair(List<String> postNames,
String currentName) {
FixedSizeSlidingWindow<String> window = new FixedSizeSlidingWindow<>(3);
for (String postName : postNames) {
window.add(postName);
if (!window.isFull()) {
continue;
}
int index = window.indexOf(currentName);
if (index == -1) {
continue;
}
// got expected window
if (index < 2) {
break;
}
}
List<String> elements = window.elements();
// current post index
int index = elements.indexOf(currentName);
String previousPostName = null;
if (index > 0) {
previousPostName = elements.get(index - 1);
}
String nextPostName = null;
if (elements.size() - 1 > index) {
nextPostName = elements.get(index + 1);
}
return Pair.of(previousPostName, nextPostName);
}
static class FixedSizeSlidingWindow<T> {
Deque<T> queue;
int size;
public FixedSizeSlidingWindow(int size) {
this.size = size;
// FIFO
queue = new ArrayDeque<>(size);
}
/**
* Add element to the window.
* The element added first will be deleted when the element in the collection exceeds
* {@code size}.
*/
public void add(T t) {
if (queue.size() == size) {
// remove first
queue.poll();
}
// add to last
queue.add(t);
}
public int indexOf(T o) {
List<T> elements = elements();
return elements.indexOf(o);
}
public List<T> elements() {
return new ArrayList<>(queue);
}
public boolean isFull() {
return queue.size() == size;
}
public Mono<NavigationPostVo> cursor(String currentName) {
return postPredicateResolver.getListOptions()
.flatMapMany(postListOption ->
client.listAll(Post.class, postListOption, defaultSort())
)
.map(post -> post.getMetadata().getName())
.collectList()
.flatMap(postNames -> Mono.just(NavigationPostVo.builder())
.flatMap(builder -> getByName(currentName)
.doOnNext(builder::current)
.thenReturn(builder)
)
.flatMap(builder -> {
var previousNextPair = findPostNavigation(postNames, currentName);
String previousPostName = previousNextPair.prev();
String nextPostName = previousNextPair.next();
return fetchByName(previousPostName)
.doOnNext(builder::previous)
.then(fetchByName(nextPostName))
.doOnNext(builder::next)
.thenReturn(builder);
})
.map(NavigationPostVo.NavigationPostVoBuilder::build))
.defaultIfEmpty(NavigationPostVo.empty());
}
@Override
public Mono<ListResult<ListedPostVo>> list(Integer page, Integer size) {
return postPublicQueryService.list(page, size, null, defaultComparator());
return postPublicQueryService.list(new ListOptions(), getPageRequest(page, size));
}
private PageRequestImpl getPageRequest(Integer page, Integer size) {
return PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultSort());
}
@Override
public Mono<ListResult<ListedPostVo>> listByCategory(Integer page, Integer size,
String categoryName) {
return postPublicQueryService.list(page, size,
post -> contains(post.getSpec().getCategories(), categoryName), defaultComparator());
var fieldQuery = QueryFactory.all();
if (StringUtils.isNotBlank(categoryName)) {
fieldQuery =
QueryFactory.and(fieldQuery, QueryFactory.equal("spec.categories", categoryName));
}
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return postPublicQueryService.list(listOptions, getPageRequest(page, size));
}
@Override
public Mono<ListResult<ListedPostVo>> listByTag(Integer page, Integer size, String tag) {
return postPublicQueryService.list(page, size,
post -> contains(post.getSpec().getTags(), tag), defaultComparator());
var fieldQuery = QueryFactory.all();
if (StringUtils.isNotBlank(tag)) {
fieldQuery =
QueryFactory.and(fieldQuery, QueryFactory.equal("spec.tags", tag));
}
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return postPublicQueryService.list(listOptions, getPageRequest(page, size));
}
@Override
public Mono<ListResult<ListedPostVo>> listByOwner(Integer page, Integer size, String owner) {
return postPublicQueryService.list(page, size,
post -> post.getSpec().getOwner().equals(owner), defaultComparator());
var fieldQuery = QueryFactory.all();
if (StringUtils.isNotBlank(owner)) {
fieldQuery =
QueryFactory.and(fieldQuery, QueryFactory.equal("spec.owner", owner));
}
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return postPublicQueryService.list(listOptions, getPageRequest(page, size));
}
@Override
@ -217,23 +187,23 @@ public class PostFinderImpl implements PostFinder {
@Override
public Mono<ListResult<PostArchiveVo>> archives(Integer page, Integer size, String year,
String month) {
return postPublicQueryService.list(page, size, post -> {
Map<String, String> labels = post.getMetadata().getLabels();
if (labels == null) {
return false;
}
boolean yearMatch = StringUtils.isBlank(year)
|| year.equals(labels.get(Post.ARCHIVE_YEAR_LABEL));
boolean monthMatch = StringUtils.isBlank(month)
|| month.equals(labels.get(Post.ARCHIVE_MONTH_LABEL));
return yearMatch && monthMatch;
}, archiveComparator())
var listOptions = new ListOptions();
var labelSelectorBuilder = LabelSelector.builder();
if (StringUtils.isNotBlank(year)) {
labelSelectorBuilder.eq(Post.ARCHIVE_YEAR_LABEL, year);
}
if (StringUtils.isNotBlank(month)) {
labelSelectorBuilder.eq(Post.ARCHIVE_MONTH_LABEL, month);
}
listOptions.setLabelSelector(labelSelectorBuilder.build());
var pageRequest = PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), archiveSort());
return postPublicQueryService.list(listOptions, pageRequest)
.map(list -> {
Map<String, List<ListedPostVo>> yearPosts = list.get()
.collect(Collectors.groupingBy(
post -> HaloUtils.getYearText(post.getSpec().getPublishTime())));
List<PostArchiveVo> postArchives =
yearPosts.entrySet().stream().map(entry -> {
List<PostArchiveVo> postArchives = yearPosts.entrySet().stream()
.map(entry -> {
String key = entry.getKey();
// archives by month
Map<String, List<ListedPostVo>> monthPosts = entry.getValue().stream()
@ -260,37 +230,24 @@ public class PostFinderImpl implements PostFinder {
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
postArchives);
})
.defaultIfEmpty(new ListResult<>(page, size, 0, List.of()));
.defaultIfEmpty(ListResult.emptyResult());
}
private boolean contains(List<String> c, String key) {
if (StringUtils.isBlank(key) || c == null) {
return false;
}
return c.contains(key);
@Override
public Flux<ListedPostVo> listAll() {
return postPredicateResolver.getListOptions()
.flatMapMany(listOptions -> client.listAll(Post.class, listOptions, defaultSort()))
.concatMap(postPublicQueryService::convertToListedVo);
}
static Comparator<Post> defaultComparator() {
Function<Post, Boolean> pinned =
post -> Objects.requireNonNullElse(post.getSpec().getPinned(), false);
Function<Post, Integer> priority =
post -> Objects.requireNonNullElse(post.getSpec().getPriority(), 0);
Function<Post, Instant> publishTime =
post -> post.getSpec().getPublishTime();
Function<Post, String> name = post -> post.getMetadata().getName();
return Comparator.comparing(pinned)
.thenComparing(priority)
.thenComparing(publishTime, Comparators.nullsLow())
.thenComparing(name)
.reversed();
int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}
static Comparator<Post> archiveComparator() {
Function<Post, Instant> publishTime =
post -> post.getSpec().getPublishTime();
Function<Post, String> name = post -> post.getMetadata().getName();
return Comparator.comparing(publishTime, Comparators.nullsLow())
.thenComparing(name)
.reversed();
int sizeNullSafe(Integer size) {
return ObjectUtils.defaultIfNull(size, 10);
}
record LinkNavigation(String prev, String current, String next) {
}
}

View File

@ -1,11 +1,8 @@
package run.halo.app.theme.finders.impl;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@ -15,7 +12,9 @@ import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostService;
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.ReactiveExtensionClient;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
@ -52,13 +51,21 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
private final ReactiveQueryPostPredicateResolver postPredicateResolver;
@Override
public Mono<ListResult<ListedPostVo>> list(Integer page, Integer size,
Predicate<Post> postPredicate, Comparator<Post> comparator) {
return postPredicateResolver.getPredicate()
.map(predicate -> predicate.and(postPredicate == null ? post -> true : postPredicate))
.flatMap(predicate -> client.list(Post.class, predicate,
comparator, pageNullSafe(page), sizeNullSafe(size))
)
public Mono<ListResult<ListedPostVo>> list(ListOptions queryOptions, PageRequest page) {
return postPredicateResolver.getListOptions()
.map(option -> {
var fieldSelector = queryOptions.getFieldSelector();
if (fieldSelector != null) {
option.setFieldSelector(option.getFieldSelector()
.andQuery(fieldSelector.query()));
}
var labelSelector = queryOptions.getLabelSelector();
if (labelSelector != null) {
option.setLabelSelector(option.getLabelSelector().and(labelSelector));
}
return option;
})
.flatMap(listOptions -> client.listBy(Post.class, listOptions, page))
.flatMap(list -> Flux.fromStream(list.get())
.concatMap(post -> convertToListedVo(post)
.flatMap(postVo -> populateStats(postVo)
@ -70,9 +77,10 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
postVos)
)
)
.defaultIfEmpty(new ListResult<>(page, size, 0L, List.of()));
.defaultIfEmpty(ListResult.emptyResult());
}
@Override
public Mono<ListedPostVo> convertToListedVo(@NonNull Post post) {
Assert.notNull(post, "Post must not be null");
@ -180,12 +188,4 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
)
.defaultIfEmpty(StatsVo.empty());
}
int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}
int sizeNullSafe(Integer size) {
return ObjectUtils.defaultIfNull(size, 10);
}
}

View File

@ -1,11 +1,20 @@
package run.halo.app.theme.finders.impl;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Counter;
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.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.SiteStatsFinder;
import run.halo.app.theme.finders.vo.SiteStatsVo;
@ -40,14 +49,22 @@ public class SiteStatsFinderImpl implements SiteStatsFinder {
}
Mono<Integer> postCount() {
return client.list(Post.class, post -> !post.isDeleted() && post.isPublished(), null)
.count()
.map(Long::intValue);
var listOptions = new ListOptions();
listOptions.setLabelSelector(LabelSelector.builder()
.eq(Post.PUBLISHED_LABEL, "true")
.build());
var fieldQuery = and(
isNull("metadata.deletionTimestamp"),
equal("spec.deleted", "false")
);
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return client.listBy(Post.class, listOptions, PageRequestImpl.ofSize(1))
.map(result -> (int) result.getTotal());
}
Mono<Integer> categoryCount() {
return client.list(Category.class, null, null)
.count()
return client.listBy(Category.class, new ListOptions(), PageRequestImpl.ofSize(1))
.map(ListResult::getTotal)
.map(Long::intValue);
}

View File

@ -6,11 +6,16 @@ import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Tag;
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.theme.finders.Finder;
import run.halo.app.theme.finders.TagFinder;
@ -48,7 +53,8 @@ public class TagFinderImpl implements TagFinder {
@Override
public Mono<ListResult<TagVo>> list(Integer page, Integer size) {
return list(page, size, null, null);
return listBy(new ListOptions(),
PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size)));
}
@Override
@ -68,13 +74,34 @@ public class TagFinderImpl implements TagFinder {
new ListResult<>(pageNullSafe(page), sizeNullSafe(size), 0L, List.of()));
}
@Override
public List<TagVo> convertToVo(List<Tag> tags) {
if (CollectionUtils.isEmpty(tags)) {
return List.of();
}
return tags.stream()
.map(TagVo::from)
.collect(Collectors.toList());
}
@Override
public Flux<TagVo> listAll() {
return client.list(Tag.class, null,
DEFAULT_COMPARATOR.reversed())
return client.listAll(Tag.class, new ListOptions(),
Sort.by(Sort.Order.desc("metadata.creationTimestamp")))
.map(TagVo::from);
}
private Mono<ListResult<TagVo>> listBy(ListOptions listOptions, PageRequest pageRequest) {
return client.listBy(Tag.class, listOptions, pageRequest)
.map(result -> {
List<TagVo> tagVos = result.get()
.map(TagVo::from)
.collect(Collectors.toList());
return new ListResult<>(result.getPage(), result.getSize(), result.getTotal(),
tagVos);
});
}
int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}

View File

@ -1,5 +1,10 @@
package run.halo.app.theme.router;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import static run.halo.app.extension.index.query.QueryFactory.or;
import java.security.Principal;
import java.util.Objects;
import java.util.function.Predicate;
@ -9,6 +14,9 @@ import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
import run.halo.app.infra.AnonymousUserConst;
/**
@ -34,6 +42,28 @@ public class DefaultQueryPostPredicateResolver implements ReactiveQueryPostPredi
.defaultIfEmpty(predicate.and(visiblePredicate));
}
@Override
public Mono<ListOptions> getListOptions() {
var listOptions = new ListOptions();
listOptions.setLabelSelector(LabelSelector.builder()
.eq(Post.PUBLISHED_LABEL, "true").build());
var fieldQuery = and(
isNull("metadata.deletionTimestamp"),
equal("spec.deleted", "false")
);
var visibleQuery = equal("spec.visible", Post.VisibleEnum.PUBLIC.name());
return currentUserName()
.map(username -> and(fieldQuery,
or(visibleQuery, equal("spec.owner", username)))
)
.defaultIfEmpty(and(fieldQuery, visibleQuery))
.map(query -> {
listOptions.setFieldSelector(FieldSelector.of(query));
return listOptions;
});
}
Mono<String> currentUserName() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)

View File

@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationListener;
import org.springframework.data.domain.Sort;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Category;
@ -14,6 +15,7 @@ import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.theme.DefaultTemplateEnum;
@ -52,7 +54,7 @@ public class ExtensionPermalinkPatternUpdater
private void updatePostPermalink(String pattern) {
log.debug("Update post permalink by new policy [{}]", pattern);
client.list(Post.class, null, null)
client.listAll(Post.class, new ListOptions(), Sort.unsorted())
.forEach(post -> updateIfPermalinkPatternChanged(post, pattern));
}
@ -70,13 +72,13 @@ public class ExtensionPermalinkPatternUpdater
private void updateCategoryPermalink(String pattern) {
log.debug("Update category and categories permalink by new policy [{}]", pattern);
client.list(Category.class, null, null)
client.listAll(Category.class, new ListOptions(), Sort.unsorted())
.forEach(category -> updateIfPermalinkPatternChanged(category, pattern));
}
private void updateTagPermalink(String pattern) {
log.debug("Update tag and tags permalink by new policy [{}]", pattern);
client.list(Tag.class, null, null)
client.listAll(Tag.class, new ListOptions(), Sort.unsorted())
.forEach(tag -> updateIfPermalinkPatternChanged(tag, pattern));
}
}

View File

@ -3,6 +3,7 @@ package run.halo.app.theme.router;
import java.util.function.Predicate;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListOptions;
/**
* The reactive query post predicate resolver.
@ -13,4 +14,6 @@ import run.halo.app.core.extension.content.Post;
public interface ReactiveQueryPostPredicateResolver {
Mono<Predicate<Post>> getPredicate();
Mono<ListOptions> getListOptions();
}

View File

@ -17,7 +17,12 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.i18n.LocaleContextResolver;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.exception.NotFoundException;
@ -81,10 +86,18 @@ public class CategoryPostRouteFactory implements RouteFactory {
}
Mono<CategoryVo> fetchBySlug(String slug) {
return client.list(Category.class, category -> category.getSpec().getSlug().equals(slug)
&& category.getMetadata().getDeletionTimestamp() == null, null)
.next()
.map(CategoryVo::from);
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(
QueryFactory.and(
QueryFactory.equal("spec.slug", slug),
QueryFactory.isNull("metadata.deletionTimestamp")
)
));
return client.listBy(Category.class, listOptions, PageRequestImpl.ofSize(1))
.mapNotNull(result -> ListResult.first(result)
.map(CategoryVo::from)
.orElse(null)
);
}
private Mono<UrlContextListResult<ListedPostVo>> postListByCategoryName(String name,

View File

@ -15,6 +15,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
@ -32,6 +33,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.DefaultTemplateEnum;
@ -159,11 +161,14 @@ public class PostRouteFactory implements RouteFactory {
}
private Flux<Post> fetchPostsBySlug(String slug) {
return queryPostPredicateResolver.getPredicate()
.flatMapMany(predicate -> client.list(Post.class,
predicate.and(post -> matchIfPresent(slug, post.getSpec().getSlug())),
null)
);
return queryPostPredicateResolver.getListOptions()
.flatMapMany(listOptions -> {
if (StringUtils.isNotBlank(slug)) {
var other = QueryFactory.equal("spec.slug", slug);
listOptions.setFieldSelector(listOptions.getFieldSelector().andQuery(other));
}
return client.listAll(Post.class, listOptions, Sort.unsorted());
});
}
private boolean matchIfPresent(String variable, String target) {

View File

@ -15,7 +15,12 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.i18n.LocaleContextResolver;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.exception.NotFoundException;
@ -95,9 +100,14 @@ public class TagPostRouteFactory implements RouteFactory {
}
private Mono<TagVo> tagBySlug(String slug) {
return client.list(Tag.class, tag -> tag.getSpec().getSlug().equals(slug)
&& tag.getMetadata().getDeletionTimestamp() == null, null)
.next()
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(
QueryFactory.and(QueryFactory.equal("spec.slug", slug),
QueryFactory.isNull("metadata.deletionTimestamp")
)
));
return client.listBy(Tag.class, listOptions, PageRequestImpl.ofSize(1))
.mapNotNull(result -> ListResult.first(result).orElse(null))
.flatMap(tag -> tagFinder.getByName(tag.getMetadata().getName()))
.switchIfEmpty(
Mono.error(new NotFoundException("Tag not found with slug: " + slug)));

View File

@ -1,368 +0,0 @@
package run.halo.app.content;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link DefaultIndexer}.
*
* @author guqing
* @since 2.0.0
*/
class DefaultIndexerTest {
@Test
public void testTagsIndexer() throws JSONException {
// Create a new Indexer that indexes Post objects by tags.
DefaultIndexer<Post> indexer = new DefaultIndexer<>();
String tagsIndexName = "tags";
indexer.addIndexFunc(tagsIndexName, post -> {
List<String> tags = post.getSpec().getTags();
return tags == null ? Set.of() : Set.copyOf(tags);
});
// Create some Post objects.
Post post1 = new Post();
post1.setMetadata(new Metadata());
post1.getMetadata().setName("post-1");
post1.setSpec(new Post.PostSpec());
post1.getSpec().setTags(List.of("t1", "t2"));
Post post2 = new Post();
post2.setMetadata(new Metadata());
post2.getMetadata().setName("post-2");
post2.setSpec(new Post.PostSpec());
post2.getSpec().setTags(List.of("t2", "t3"));
Post post3 = new Post();
post3.setMetadata(new Metadata());
post3.getMetadata().setName("post-3");
post3.setSpec(new Post.PostSpec());
post3.getSpec().setTags(List.of("t3", "t4"));
// Add the Post objects to the Indexer.
indexer.add(tagsIndexName, post1);
indexer.add(tagsIndexName, post2);
indexer.add(tagsIndexName, post3);
// Verify that the Indexer has the correct indices.
JSONAssert.assertEquals("""
{
"t4": [
"post-3"
],
"t3": [
"post-2",
"post-3"
],
"t2": [
"post-1",
"post-2"
],
"t1": [
"post-1"
]
}
""",
JsonUtils.objectToJson(indexer.getIndices("tags")),
true);
// Remove post2 from the Indexer.
indexer.delete(tagsIndexName, post2);
// Verify that the Indexer has the correct indices.
JSONAssert.assertEquals("""
{
"t1": [
"post-1"
],
"t2": [
"post-1"
],
"t3": [
"post-3"
],
"t4": [
"post-3"
]
}
""",
JsonUtils.objectToJson(indexer.getIndices("tags")),
true);
// Update post3 in the Indexer.
post3.getSpec().setTags(List.of("t4", "t5"));
indexer.update(tagsIndexName, post3);
// Verify that the Indexer has the correct indices.
JSONAssert.assertEquals("""
{
"t1": [
"post-1"
],
"t2": [
"post-1"
],
"t4": [
"post-3"
],
"t5": [
"post-3"
]
}
""",
JsonUtils.objectToJson(indexer.getIndices("tags")),
true);
}
@Test
public void testLabelIndexer() throws JSONException {
// Create a new Indexer.
DefaultIndexer<Post> indexer = new DefaultIndexer<>();
// Define the IndexFunc for labels.
DefaultIndexer.IndexFunc<Post> labelIndexFunc = labelIndexFunc();
// Add the label IndexFunc to the Indexer.
String labelsIndexName = "labels";
indexer.addIndexFunc(labelsIndexName, labelIndexFunc);
// Create some posts with labels.
Post post1 = new Post();
post1.setMetadata(new Metadata());
post1.getMetadata().setName("post-1");
post1.getMetadata().setLabels(Map.of("app", "myapp", "env", "prod"));
Post post2 = new Post();
post2.setMetadata(new Metadata());
post2.getMetadata().setName("post-2");
post2.getMetadata().setLabels(Map.of("app", "myapp", "env", "test"));
Post post3 = new Post();
post3.setMetadata(new Metadata());
post3.getMetadata().setName("post-3");
post3.getMetadata().setLabels(Map.of("app", "otherapp", "env", "prod"));
// Add the posts to the Indexer.
indexer.add(labelsIndexName, post1);
indexer.add(labelsIndexName, post2);
indexer.add(labelsIndexName, post3);
// Verify that the Indexer has the correct indices.
assertEquals(
Map.of(
"app=myapp", Set.of("post-1", "post-2"),
"app=otherapp", Set.of("post-3"),
"env=test", Set.of("post-2"),
"env=prod", Set.of("post-1", "post-3")
),
indexer.getIndices("labels"));
// Delete post2 from the Indexer.
indexer.delete(labelsIndexName, post2);
// Verify that the Indexer has the correct indices.
JSONAssert.assertEquals("""
{
"app=myapp": [
"post-1"
],
"env=prod": [
"post-1",
"post-3"
],
"app=otherapp": [
"post-3"
]
}
""",
JsonUtils.objectToJson(indexer.getIndices("labels")),
true);
// Update post2 in the Indexer.
post2.getMetadata().setLabels(Map.of("l1", "v1", "l2", "v2"));
indexer.update(labelsIndexName, post2);
// Verify that the Indexer has the correct indices.
JSONAssert.assertEquals("""
{
"app=myapp": [
"post-1"
],
"env=prod": [
"post-1",
"post-3"
],
"app=otherapp": [
"post-3"
],
"l1=v1": [
"post-2"
],
"l2=v2": [
"post-2"
]
}
""",
JsonUtils.objectToJson(indexer.getIndices("labels")),
true);
// Update post1 in the Indexer.
post1.getMetadata().setLabels(Map.of("l2", "v2", "l3", "v3"));
indexer.update(labelsIndexName, post1);
// Verify that the Indexer has the correct indices.
JSONAssert.assertEquals("""
{
"env=prod": [
"post-3"
],
"app=otherapp": [
"post-3"
],
"l1=v1": [
"post-2"
],
"l2=v2": [
"post-1",
"post-2"
],
"l3=v3": [
"post-1"
]
}
""",
JsonUtils.objectToJson(indexer.getIndices("labels")),
true);
}
@Test
void multiIndexName() {
// Create a new Indexer.
DefaultIndexer<Post> indexer = new DefaultIndexer<>();
// Define the IndexFunc for labels.
String labelsIndexName = "labels";
DefaultIndexer.IndexFunc<Post> labelIndexFunc = labelIndexFunc();
indexer.addIndexFunc(labelsIndexName, labelIndexFunc);
String tagsIndexName = "tags";
indexer.addIndexFunc(tagsIndexName, post -> {
List<String> tags = post.getSpec().getTags();
return tags == null ? Set.of() : Set.copyOf(tags);
});
Post post1 = new Post();
post1.setMetadata(new Metadata());
post1.getMetadata().setName("post-1");
post1.getMetadata().setLabels(Map.of("app", "myapp", "env", "prod"));
post1.setSpec(new Post.PostSpec());
post1.getSpec().setTags(List.of("t1", "t2"));
Post post2 = new Post();
post2.setMetadata(new Metadata());
post2.getMetadata().setName("post-2");
post2.getMetadata().setLabels(Map.of("app", "myapp", "env", "test"));
post2.setSpec(new Post.PostSpec());
post2.getSpec().setTags(List.of("t2", "t3"));
indexer.add(labelsIndexName, post1);
indexer.add(tagsIndexName, post1);
indexer.add(labelsIndexName, post2);
indexer.add(tagsIndexName, post2);
assertThat(indexer.getByIndex(labelsIndexName, "app=myapp"))
.containsExactlyInAnyOrder("post-1", "post-2");
assertThat(indexer.getByIndex(tagsIndexName, "t1"))
.containsExactlyInAnyOrder("post-1");
assertThat(indexer.getByIndex(labelsIndexName, "env=test"))
.containsExactlyInAnyOrder("post-2");
assertThat(indexer.getByIndex(tagsIndexName, "t2"))
.containsExactlyInAnyOrder("post-1", "post-2");
post2.getSpec().setTags(List.of("t1", "t4"));
indexer.update(tagsIndexName, post2);
assertThat(indexer.getByIndex(tagsIndexName, "t1"))
.containsExactlyInAnyOrder("post-1", "post-2");
assertThat(indexer.getByIndex(tagsIndexName, "t2"))
.containsExactlyInAnyOrder("post-1");
assertThat(indexer.getByIndex(tagsIndexName, "t4"))
.containsExactlyInAnyOrder("post-2");
}
private static DefaultIndexer.IndexFunc<Post> labelIndexFunc() {
return post -> {
Map<String, String> labels = post.getMetadata().getLabels();
Set<String> indexKeys = new HashSet<>();
if (labels != null) {
for (Map.Entry<String, String> entry : labels.entrySet()) {
indexKeys.add(entry.getKey() + "=" + entry.getValue());
}
}
return indexKeys;
};
}
@Test
void getByIndex() {
DefaultIndexer<Post> indexer = new DefaultIndexer<>();
String tagsIndexName = "tags";
indexer.addIndexFunc(tagsIndexName, post -> {
List<String> tags = post.getSpec().getTags();
return tags == null ? Set.of() : Set.copyOf(tags);
});
// Create some Post objects.
Post post1 = new Post();
post1.setMetadata(new Metadata());
post1.getMetadata().setName("post-1");
post1.setSpec(new Post.PostSpec());
post1.getSpec().setTags(List.of("t1", "t2"));
Post post2 = new Post();
post2.setMetadata(new Metadata());
post2.getMetadata().setName("post-2");
post2.setSpec(new Post.PostSpec());
post2.getSpec().setTags(List.of("t2", "t3"));
indexer.add(tagsIndexName, post1);
indexer.add(tagsIndexName, post2);
assertThat(indexer.getByIndex(tagsIndexName, "t1"))
.containsExactlyInAnyOrder("post-1");
assertThat(indexer.getByIndex(tagsIndexName, "t2"))
.containsExactlyInAnyOrder("post-1", "post-2");
assertThat(indexer.getByIndex(tagsIndexName, "t3"))
.containsExactlyInAnyOrder("post-2");
}
@Test
void addButNotIndexFunc() {
// Create some Post objects.
Post post1 = new Post();
post1.setMetadata(new Metadata());
post1.getMetadata().setName("post-1");
post1.setSpec(new Post.PostSpec());
post1.getSpec().setTags(List.of("t1", "t2"));
// Create a new Indexer that indexes Post objects by tags.
final DefaultIndexer<Post> indexer = new DefaultIndexer<>();
assertThrows(IllegalArgumentException.class, () -> {
indexer.add("fake-index-name", post1);
}, "Index function not found for index name 'fake-index-name'");
}
}

View File

@ -3,6 +3,7 @@ package run.halo.app.content;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
@ -12,7 +13,7 @@ import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.index.query.QueryIndexViewImpl;
/**
* Tests for {@link PostQuery}.
@ -32,61 +33,20 @@ class PostQueryTest {
.build();
PostQuery postQuery = new PostQuery(request, "faker");
var spec = new Post.PostSpec();
var post = new Post();
post.setSpec(spec);
spec.setOwner("another-faker");
assertThat(postQuery.toPredicate().test(post)).isFalse();
var listOptions = postQuery.toListOptions();
assertThat(listOptions).isNotNull();
assertThat(listOptions.getFieldSelector()).isNotNull();
var nameEntry =
(Collection<Map.Entry<String, String>>) List.of(Map.entry("metadata.name", "faker"));
var entry = (Collection<Map.Entry<String, String>>) List.of(Map.entry("faker", "faker"));
var indexView =
new QueryIndexViewImpl(Map.of("spec.owner", entry, "metadata.name", nameEntry));
assertThat(listOptions.getFieldSelector().query().matches(indexView))
.containsExactly("faker");
spec.setOwner("faker");
assertThat(postQuery.toPredicate().test(post)).isTrue();
}
@Test
void toPredicate() {
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
multiValueMap.put("category", List.of("category1", "category2"));
MockServerRequest request = MockServerRequest.builder()
.queryParams(multiValueMap)
.exchange(mock(ServerWebExchange.class))
.build();
PostQuery postQuery = new PostQuery(request);
Post post = TestPost.postV1();
post.getSpec().setTags(null);
post.getStatusOrDefault().setContributors(null);
post.getSpec().setCategories(List.of("category1"));
boolean test = postQuery.toPredicate().test(post);
assertThat(test).isTrue();
post.getSpec().setTags(List.of("tag1"));
test = postQuery.toPredicate().test(post);
assertThat(test).isTrue();
// Do not include tags
multiValueMap.put("tag", List.of("tag2"));
post.getSpec().setTags(List.of("tag1"));
post.getSpec().setCategories(null);
test = postQuery.toPredicate().test(post);
assertThat(test).isFalse();
multiValueMap.put("tag", List.of());
multiValueMap.remove("category");
request = MockServerRequest.builder()
.exchange(mock(ServerWebExchange.class))
.queryParams(multiValueMap).build();
postQuery = new PostQuery(request);
post.getSpec().setTags(List.of());
test = postQuery.toPredicate().test(post);
assertThat(test).isTrue();
multiValueMap.put("labelSelector", List.of("hello"));
test = postQuery.toPredicate().test(post);
assertThat(test).isFalse();
post.getMetadata().setLabels(Map.of("hello", "world"));
test = postQuery.toPredicate().test(post);
assertThat(test).isTrue();
entry = List.of(Map.entry("another-faker", "user1"));
indexView = new QueryIndexViewImpl(Map.of("spec.owner", entry, "metadata.name", nameEntry));
assertThat(listOptions.getFieldSelector().query().matches(indexView)).isEmpty();
}
}

View File

@ -10,18 +10,19 @@ import static org.mockito.Mockito.verify;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import run.halo.app.content.TestPost;
import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
@ -43,7 +44,7 @@ class CategoryReconcilerTest {
private CategoryReconciler categoryReconciler;
@Test
void reconcileStatusPostForCategoryA() throws JSONException {
void reconcileStatusPostForCategoryA() {
reconcileStatusPostPilling("category-A");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
@ -54,7 +55,7 @@ class CategoryReconcilerTest {
}
@Test
void reconcileStatusPostForCategoryB() throws JSONException {
void reconcileStatusPostForCategoryB() {
reconcileStatusPostPilling("category-B");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(3)).update(captor.capture());
@ -64,7 +65,7 @@ class CategoryReconcilerTest {
}
@Test
void reconcileStatusPostForCategoryC() throws JSONException {
void reconcileStatusPostForCategoryC() {
reconcileStatusPostPilling("category-C");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(3)).update(captor.capture());
@ -74,7 +75,7 @@ class CategoryReconcilerTest {
}
@Test
void reconcileStatusPostForCategoryD() throws JSONException {
void reconcileStatusPostForCategoryD() {
reconcileStatusPostPilling("category-D");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(3)).update(captor.capture());
@ -89,9 +90,9 @@ class CategoryReconcilerTest {
.thenReturn(Optional.of(category));
});
lenient().when(client.list(eq(Post.class), any(), any()))
lenient().when(client.listAll(eq(Post.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(posts());
lenient().when(client.list(eq(Category.class), any(), any()))
lenient().when(client.listAll(eq(Category.class), any(), any()))
.thenReturn(categories());
Reconciler.Result result =

View File

@ -85,7 +85,7 @@ class PostReconcilerTest {
Snapshot snapshotV2 = TestPost.snapshotV2();
snapshotV1.getSpec().setContributors(Set.of("guqing"));
snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan"));
when(client.list(eq(Snapshot.class), any(), any()))
when(client.listAll(eq(Snapshot.class), any(), any()))
.thenReturn(List.of(snapshotV1, snapshotV2));
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
@ -126,7 +126,7 @@ class PostReconcilerTest {
Snapshot snapshotV1 = TestPost.snapshotV1();
snapshotV1.getSpec().setContributors(Set.of("guqing"));
when(client.list(eq(Snapshot.class), any(), any()))
when(client.listAll(eq(Snapshot.class), any(), any()))
.thenReturn(List.of(snapshotV1, snapshotV2));
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
@ -162,7 +162,7 @@ class PostReconcilerTest {
when(client.fetch(eq(Snapshot.class), eq(post.getSpec().getReleaseSnapshot())))
.thenReturn(Optional.of(snapshotV2));
when(client.list(eq(Snapshot.class), any(), any()))
when(client.listAll(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
@ -191,7 +191,7 @@ class PostReconcilerTest {
.rawType("markdown")
.build()));
when(client.list(eq(Snapshot.class), any(), any()))
when(client.listAll(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);

View File

@ -95,7 +95,7 @@ class SinglePageReconcilerTest {
Snapshot snapshotV2 = TestPost.snapshotV2();
snapshotV1.getSpec().setContributors(Set.of("guqing"));
snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan"));
when(client.list(eq(Snapshot.class), any(), any()))
when(client.listAll(eq(Snapshot.class), any(), any()))
.thenReturn(List.of(snapshotV1, snapshotV2));
when(externalUrlSupplier.get()).thenReturn(URI.create(""));
@ -156,7 +156,7 @@ class SinglePageReconcilerTest {
when(client.fetch(eq(Snapshot.class), eq(page.getSpec().getReleaseSnapshot())))
.thenReturn(Optional.of(snapshotV2));
when(client.list(eq(Snapshot.class), any(), any()))
when(client.listAll(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<SinglePage> captor = ArgumentCaptor.forClass(SinglePage.class);
@ -186,7 +186,7 @@ class SinglePageReconcilerTest {
.build())
);
when(client.list(eq(Snapshot.class), any(), any()))
when(client.listAll(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<SinglePage> captor = ArgumentCaptor.forClass(SinglePage.class);

View File

@ -10,15 +10,14 @@ import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import run.halo.app.content.PostIndexInformer;
import run.halo.app.content.permalinks.TagPermalinkPolicy;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
@ -37,9 +36,6 @@ class TagReconcilerTest {
@Mock
private TagPermalinkPolicy tagPermalinkPolicy;
@Mock
private PostIndexInformer postIndexInformer;
@InjectMocks
private TagReconciler tagReconciler;
@ -48,8 +44,7 @@ class TagReconcilerTest {
Tag tag = tag();
when(client.fetch(eq(Tag.class), eq("fake-tag")))
.thenReturn(Optional.of(tag));
when(postIndexInformer.getByTagName(eq("fake-tag")))
.thenReturn(Set.of());
when(client.listAll(eq(Post.class), any(), any())).thenReturn(List.of());
when(tagPermalinkPolicy.permalink(any()))
.thenAnswer(arg -> "/tags/" + tag.getSpec().getSlug());
ArgumentCaptor<Tag> captor = ArgumentCaptor.forClass(Tag.class);
@ -85,8 +80,8 @@ class TagReconcilerTest {
Tag tag = tag();
when(client.fetch(eq(Tag.class), eq("fake-tag")))
.thenReturn(Optional.of(tag));
when(postIndexInformer.getByTagName(eq("fake-tag")))
.thenReturn(Set.of("fake-post-1", "fake-post-3"));
when(client.listAll(eq(Post.class), any(), any()))
.thenReturn(List.of(createPost("fake-post-1"), createPost("fake-post-2")));
ArgumentCaptor<Tag> captor = ArgumentCaptor.forClass(Tag.class);
tagReconciler.reconcile(new TagReconciler.Request("fake-tag"));
@ -96,6 +91,14 @@ class TagReconcilerTest {
assertThat(allValues.get(1).getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
}
Post createPost(String name) {
var post = new Post();
post.setMetadata(new Metadata());
post.getMetadata().setName(name);
post.setSpec(new Post.PostSpec());
return post;
}
Tag tag() {
Tag tag = new Tag();
tag.setMetadata(new Metadata());

View File

@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.ParameterizedType;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class ListResultTest {
@ -53,6 +54,15 @@ class ListResultTest {
assertSubList(list);
}
@Test
void firstTest() {
var listResult = new ListResult<>(List.of());
assertEquals(Optional.empty(), ListResult.first(listResult));
listResult = new ListResult<>(1, 10, 1, List.of("A"));
assertEquals(Optional.of("A"), ListResult.first(listResult));
}
private void assertSubList(List<Integer> list) {
var result = ListResult.subList(list, 0, 0);
assertEquals(list, result);

View File

@ -2,7 +2,6 @@ package run.halo.app.theme.endpoint;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ -19,8 +18,10 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.vo.ListedPostVo;
@ -52,7 +53,7 @@ class CategoryQueryEndpointTest {
@Test
void listCategories() {
ListResult<Category> listResult = new ListResult<>(List.of());
when(client.list(eq(Category.class), any(), any(), anyInt(), anyInt()))
when(client.listBy(eq(Category.class), any(ListOptions.class), any(PageRequest.class)))
.thenReturn(Mono.just(listResult));
webTestClient.get()
@ -84,7 +85,7 @@ class CategoryQueryEndpointTest {
@Test
void listPostsByCategoryName() {
ListResult<ListedPostVo> listResult = new ListResult<>(List.of());
when(postPublicQueryService.list(anyInt(), anyInt(), any(), any()))
when(postPublicQueryService.list(any(), any(PageRequest.class)))
.thenReturn(Mono.just(listResult));
webTestClient.get()

View File

@ -2,7 +2,6 @@ package run.halo.app.theme.endpoint;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -20,6 +19,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.vo.ListedPostVo;
@ -55,7 +55,7 @@ class PostQueryEndpointTest {
@Test
public void listPosts() {
ListResult<ListedPostVo> result = new ListResult<>(List.of());
when(postPublicQueryService.list(anyInt(), anyInt(), any(), any()))
when(postPublicQueryService.list(any(), any(PageRequest.class)))
.thenReturn(Mono.just(result));
webClient.get().uri("/posts")
@ -65,7 +65,7 @@ class PostQueryEndpointTest {
.expectBody()
.jsonPath("$.items").isArray();
verify(postPublicQueryService).list(anyInt(), anyInt(), any(), any());
verify(postPublicQueryService).list(any(), any(PageRequest.class));
}
@Test

View File

@ -2,7 +2,6 @@ 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.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ -18,11 +17,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.finders.vo.CategoryTreeVo;
@ -85,7 +87,7 @@ class CategoryFinderImplTest {
categories().stream()
.sorted(CategoryFinderImpl.defaultComparator())
.toList());
when(client.list(eq(Category.class), eq(null), any(), anyInt(), anyInt()))
when(client.listBy(eq(Category.class), any(ListOptions.class), any(PageRequest.class)))
.thenReturn(Mono.just(categories));
ListResult<CategoryVo> list = categoryFinder.list(1, 10).block();
assertThat(list.getItems()).hasSize(3);
@ -95,7 +97,7 @@ class CategoryFinderImplTest {
@Test
void listAsTree() {
when(client.list(eq(Category.class), eq(null), any()))
when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.fromIterable(categoriesForTree()));
List<CategoryTreeVo> treeVos = categoryFinder.listAsTree().collectList().block();
assertThat(treeVos).hasSize(1);
@ -103,7 +105,7 @@ class CategoryFinderImplTest {
@Test
void listSubTreeByName() {
when(client.list(eq(Category.class), eq(null), any()))
when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.fromIterable(categoriesForTree()));
List<CategoryTreeVo> treeVos = categoryFinder.listAsTree("E").collectList().block();
assertThat(treeVos.get(0).getMetadata().getName()).isEqualTo("E");
@ -119,7 +121,7 @@ class CategoryFinderImplTest {
*/
@Test
void listAsTreeMore() {
when(client.list(eq(Category.class), eq(null), any()))
when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.fromIterable(moreCategories()));
List<CategoryTreeVo> treeVos = categoryFinder.listAsTree().collectList().block();
String s = visualizeTree(treeVos);

View File

@ -2,7 +2,6 @@ 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.anyInt;
import static org.mockito.Mockito.when;
import java.time.Instant;
@ -11,8 +10,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.logging.log4j.util.Strings;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -23,6 +20,7 @@ import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.metrics.CounterService;
import run.halo.app.theme.finders.CategoryFinder;
@ -67,15 +65,6 @@ class PostFinderImplTest {
@InjectMocks
private PostFinderImpl postFinder;
@Test
void compare() {
List<String> strings = posts().stream().sorted(PostFinderImpl.defaultComparator())
.map(post -> post.getMetadata().getName())
.toList();
assertThat(strings).isEqualTo(
List.of("post-6", "post-2", "post-1", "post-5", "post-4", "post-3"));
}
@Test
void predicate() {
Predicate<Post> predicate = new DefaultQueryPostPredicateResolver().getPredicate().block();
@ -93,7 +82,7 @@ class PostFinderImplTest {
.map(ListedPostVo::from)
.toList();
ListResult<ListedPostVo> listResult = new ListResult<>(1, 10, 3, listedPostVos);
when(publicQueryService.list(anyInt(), anyInt(), any(), any()))
when(publicQueryService.list(any(), any(PageRequest.class)))
.thenReturn(Mono.just(listResult));
ListResult<PostArchiveVo> archives = postFinder.archives(1, 10).block();
@ -112,22 +101,6 @@ class PostFinderImplTest {
assertThat(items.get(1).getMonths().get(0).getMonth()).isEqualTo("01");
}
@Test
void fixedSizeSlidingWindow() {
PostFinderImpl.FixedSizeSlidingWindow<Integer>
window = new PostFinderImpl.FixedSizeSlidingWindow<>(3);
List<String> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
window.add(i);
list.add(Strings.join(window.elements(), ','));
}
assertThat(list).isEqualTo(
List.of("0", "0,1", "0,1,2", "1,2,3", "2,3,4", "3,4,5", "4,5,6", "5,6,7", "6,7,8",
"7,8,9")
);
}
@Test
void postPreviousNextPair() {
List<String> postNames = new ArrayList<>();
@ -136,28 +109,27 @@ class PostFinderImplTest {
}
// post-0, post-1, post-2
Pair<String, String> previousNextPair =
PostFinderImpl.postPreviousNextPair(postNames, "post-0");
assertThat(previousNextPair.getLeft()).isNull();
assertThat(previousNextPair.getRight()).isEqualTo("post-1");
var previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-0");
assertThat(previousNextPair.prev()).isNull();
assertThat(previousNextPair.next()).isEqualTo("post-1");
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-1");
assertThat(previousNextPair.getLeft()).isEqualTo("post-0");
assertThat(previousNextPair.getRight()).isEqualTo("post-2");
previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-1");
assertThat(previousNextPair.prev()).isEqualTo("post-0");
assertThat(previousNextPair.next()).isEqualTo("post-2");
// post-1, post-2, post-3
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-2");
assertThat(previousNextPair.getLeft()).isEqualTo("post-1");
assertThat(previousNextPair.getRight()).isEqualTo("post-3");
previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-2");
assertThat(previousNextPair.prev()).isEqualTo("post-1");
assertThat(previousNextPair.next()).isEqualTo("post-3");
// post-7, post-8, post-9
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-8");
assertThat(previousNextPair.getLeft()).isEqualTo("post-7");
assertThat(previousNextPair.getRight()).isEqualTo("post-9");
previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-8");
assertThat(previousNextPair.prev()).isEqualTo("post-7");
assertThat(previousNextPair.next()).isEqualTo("post-9");
previousNextPair = PostFinderImpl.postPreviousNextPair(postNames, "post-9");
assertThat(previousNextPair.getLeft()).isEqualTo("post-8");
assertThat(previousNextPair.getRight()).isNull();
previousNextPair = PostFinderImpl.findPostNavigation(postNames, "post-9");
assertThat(previousNextPair.prev()).isEqualTo("post-8");
assertThat(previousNextPair.next()).isNull();
}
List<Post> postsForArchives() {

View File

@ -16,9 +16,11 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
@ -77,7 +79,7 @@ class TagFinderImplTest {
@Test
void listAll() {
when(client.list(eq(Tag.class), eq(null), any()))
when(client.listAll(eq(Tag.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.fromIterable(
tags().stream().sorted(TagFinderImpl.DEFAULT_COMPARATOR.reversed()).toList()
)

View File

@ -4,16 +4,18 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.List;
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.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.TagFinder;
@ -39,7 +41,8 @@ class TagPostRouteFactoryTest extends RouteFactoryTestSuite {
@Test
void create() {
when(client.list(eq(Tag.class), any(), eq(null))).thenReturn(Flux.empty());
when(client.listBy(eq(Tag.class), any(), any(PageRequest.class)))
.thenReturn(Mono.just(ListResult.emptyResult()));
WebTestClient webTestClient = getWebTestClient(tagPostRouteFactory.create("/new-tags"));
webTestClient.get()
@ -52,7 +55,8 @@ class TagPostRouteFactoryTest extends RouteFactoryTestSuite {
tag.getMetadata().setName("fake-tag-name");
tag.setSpec(new Tag.TagSpec());
tag.getSpec().setSlug("tag-slug-2");
when(client.list(eq(Tag.class), any(), eq(null))).thenReturn(Flux.just(tag));
when(client.listBy(eq(Tag.class), any(), any(PageRequest.class)))
.thenReturn(Mono.just(new ListResult<>(List.of(tag))));
when(tagFinder.getByName(eq(tag.getMetadata().getName())))
.thenReturn(Mono.just(TagVo.from(tag)));
webTestClient.get()

View File

@ -59,11 +59,11 @@ const {
return data.items;
},
refetchInterval: (data) => {
const deletingPosts = data?.filter(
const deletingPosts = data?.some(
(post) =>
!!post.post.metadata.deletionTimestamp || !post.post.spec.deleted
);
return deletingPosts?.length ? 1000 : false;
return deletingPosts ? 1000 : false;
},
});

View File

@ -113,21 +113,23 @@ const {
keyword,
],
queryFn: async () => {
let categories: string[] | undefined;
let tags: string[] | undefined;
let contributors: string[] | undefined;
const labelSelector: string[] = ["content.halo.run/deleted=false"];
const fieldSelector: string[] = [];
if (selectedCategory.value) {
categories = [selectedCategory.value];
fieldSelector.push(`spec.categories=${selectedCategory.value}`);
}
if (selectedTag.value) {
tags = [selectedTag.value];
fieldSelector.push(`spec.tags=${selectedTag.value}`);
}
if (selectedContributor.value) {
contributors = [selectedContributor.value];
fieldSelector.push(`status.contributors=${selectedContributor.value}`);
}
if (selectedVisible.value) {
fieldSelector.push(`spec.visible=${selectedVisible.value}`);
}
if (selectedPublishStatus.value !== undefined) {
@ -138,14 +140,11 @@ const {
const { data } = await apiClient.post.listPosts({
labelSelector,
fieldSelector,
page: page.value,
size: size.value,
visible: selectedVisible.value,
sort: [selectedSort.value].filter(Boolean) as string[],
keyword: keyword.value,
category: categories,
tag: tags,
contributor: contributors,
});
total.value = data.total;
@ -155,7 +154,7 @@ const {
return data.items;
},
refetchInterval: (data) => {
const abnormalPosts = data?.filter((post) => {
const abnormalPosts = data?.some((post) => {
const { spec, metadata, status } = post.post;
return (
spec.deleted ||
@ -164,7 +163,7 @@ const {
);
});
return abnormalPosts?.length ? 1000 : false;
return abnormalPosts ? 1000 : false;
},
});
@ -407,19 +406,19 @@ watch(selectedPostNames, (newValue) => {
},
{
label: t('core.post.filters.sort.items.publish_time_desc'),
value: 'publishTime,desc',
value: 'spec.publishTime,desc',
},
{
label: t('core.post.filters.sort.items.publish_time_asc'),
value: 'publishTime,asc',
value: 'spec.publishTime,asc',
},
{
label: t('core.post.filters.sort.items.create_time_desc'),
value: 'creationTimestamp,desc',
value: 'metadata.creationTimestamp,desc',
},
{
label: t('core.post.filters.sort.items.create_time_asc'),
value: 'creationTimestamp,asc',
value: 'metadata.creationTimestamp,asc',
},
]"
/>

View File

@ -32,6 +32,7 @@ export function usePostCategory(): usePostCategoryReturn {
await apiClient.extension.category.listcontentHaloRunV1alpha1Category({
page: 0,
size: 0,
sort: ["metadata.creationTimestamp,desc"],
});
return data.items;

View File

@ -26,6 +26,7 @@ export function usePostTag(): usePostTagReturn {
await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({
page: 0,
size: 0,
sort: ["metadata.creationTimestamp,desc"],
});
return data.items;

View File

@ -21,7 +21,7 @@ const { data } = useQuery<ListedPost[]>({
`${postLabels.DELETED}=false`,
`${postLabels.PUBLISHED}=true`,
],
sort: ["publishTime,desc"],
sort: ["spec.publishTime,desc"],
page: 1,
size: 10,
});

View File

@ -222,8 +222,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (
},
/**
* List posts.
* @param {Array<string>} [category]
* @param {Array<string>} [contributor]
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Posts filtered by keyword.
* @param {Array<string>} [labelSelector] Label selector for filtering.
@ -231,14 +229,10 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (
* @param {'DRAFT' | 'PENDING_APPROVAL' | 'PUBLISHED' | 'FAILED'} [publishPhase]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,publishTime
* @param {Array<string>} [tag]
* @param {'PUBLIC' | 'INTERNAL' | 'PRIVATE'} [visible]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPosts: async (
category?: Array<string>,
contributor?: Array<string>,
fieldSelector?: Array<string>,
keyword?: string,
labelSelector?: Array<string>,
@ -246,8 +240,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (
publishPhase?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED",
size?: number,
sort?: Array<string>,
tag?: Array<string>,
visible?: "PUBLIC" | "INTERNAL" | "PRIVATE",
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts`;
@ -274,14 +266,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (category) {
localVarQueryParameter["category"] = Array.from(category);
}
if (contributor) {
localVarQueryParameter["contributor"] = Array.from(contributor);
}
if (fieldSelector) {
localVarQueryParameter["fieldSelector"] = fieldSelector;
}
@ -310,14 +294,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (
localVarQueryParameter["sort"] = Array.from(sort);
}
if (tag) {
localVarQueryParameter["tag"] = Array.from(tag);
}
if (visible !== undefined) {
localVarQueryParameter["visible"] = visible;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
@ -710,8 +686,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function (
},
/**
* List posts.
* @param {Array<string>} [category]
* @param {Array<string>} [contributor]
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Posts filtered by keyword.
* @param {Array<string>} [labelSelector] Label selector for filtering.
@ -719,14 +693,10 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function (
* @param {'DRAFT' | 'PENDING_APPROVAL' | 'PUBLISHED' | 'FAILED'} [publishPhase]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,publishTime
* @param {Array<string>} [tag]
* @param {'PUBLIC' | 'INTERNAL' | 'PRIVATE'} [visible]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listPosts(
category?: Array<string>,
contributor?: Array<string>,
fieldSelector?: Array<string>,
keyword?: string,
labelSelector?: Array<string>,
@ -734,15 +704,11 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function (
publishPhase?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED",
size?: number,
sort?: Array<string>,
tag?: Array<string>,
visible?: "PUBLIC" | "INTERNAL" | "PRIVATE",
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedPostList>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPosts(
category,
contributor,
fieldSelector,
keyword,
labelSelector,
@ -750,8 +716,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function (
publishPhase,
size,
sort,
tag,
visible,
options
);
return createRequestFunction(
@ -954,8 +918,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (
): AxiosPromise<ListedPostList> {
return localVarFp
.listPosts(
requestParameters.category,
requestParameters.contributor,
requestParameters.fieldSelector,
requestParameters.keyword,
requestParameters.labelSelector,
@ -963,8 +925,6 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (
requestParameters.publishPhase,
requestParameters.size,
requestParameters.sort,
requestParameters.tag,
requestParameters.visible,
options
)
.then((request) => request(axios, basePath));
@ -1102,20 +1062,6 @@ export interface ApiConsoleHaloRunV1alpha1PostApiFetchPostReleaseContentRequest
* @interface ApiConsoleHaloRunV1alpha1PostApiListPostsRequest
*/
export interface ApiConsoleHaloRunV1alpha1PostApiListPostsRequest {
/**
*
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPosts
*/
readonly category?: Array<string>;
/**
*
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPosts
*/
readonly contributor?: Array<string>;
/**
* Field selector for filtering.
* @type {Array<string>}
@ -1164,20 +1110,6 @@ export interface ApiConsoleHaloRunV1alpha1PostApiListPostsRequest {
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPosts
*/
readonly sort?: Array<string>;
/**
*
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPosts
*/
readonly tag?: Array<string>;
/**
*
* @type {'PUBLIC' | 'INTERNAL' | 'PRIVATE'}
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPosts
*/
readonly visible?: "PUBLIC" | "INTERNAL" | "PRIVATE";
}
/**
@ -1339,8 +1271,6 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
) {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration)
.listPosts(
requestParameters.category,
requestParameters.contributor,
requestParameters.fieldSelector,
requestParameters.keyword,
requestParameters.labelSelector,
@ -1348,8 +1278,6 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
requestParameters.publishPhase,
requestParameters.size,
requestParameters.sort,
requestParameters.tag,
requestParameters.visible,
options
)
.then((request) => request(this.axios, this.basePath));

View File

@ -222,8 +222,6 @@ export const UcApiContentHaloRunV1alpha1PostApiAxiosParamCreator = function (
},
/**
* List posts owned by the current user.
* @param {Array<string>} [category]
* @param {Array<string>} [contributor]
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Posts filtered by keyword.
* @param {Array<string>} [labelSelector] Label selector for filtering.
@ -231,14 +229,10 @@ export const UcApiContentHaloRunV1alpha1PostApiAxiosParamCreator = function (
* @param {'DRAFT' | 'PENDING_APPROVAL' | 'PUBLISHED' | 'FAILED'} [publishPhase]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,publishTime
* @param {Array<string>} [tag]
* @param {'PUBLIC' | 'INTERNAL' | 'PRIVATE'} [visible]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listMyPosts: async (
category?: Array<string>,
contributor?: Array<string>,
fieldSelector?: Array<string>,
keyword?: string,
labelSelector?: Array<string>,
@ -246,8 +240,6 @@ export const UcApiContentHaloRunV1alpha1PostApiAxiosParamCreator = function (
publishPhase?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED",
size?: number,
sort?: Array<string>,
tag?: Array<string>,
visible?: "PUBLIC" | "INTERNAL" | "PRIVATE",
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/uc.api.content.halo.run/v1alpha1/posts`;
@ -274,14 +266,6 @@ export const UcApiContentHaloRunV1alpha1PostApiAxiosParamCreator = function (
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (category) {
localVarQueryParameter["category"] = Array.from(category);
}
if (contributor) {
localVarQueryParameter["contributor"] = Array.from(contributor);
}
if (fieldSelector) {
localVarQueryParameter["fieldSelector"] = fieldSelector;
}
@ -310,14 +294,6 @@ export const UcApiContentHaloRunV1alpha1PostApiAxiosParamCreator = function (
localVarQueryParameter["sort"] = Array.from(sort);
}
if (tag) {
localVarQueryParameter["tag"] = Array.from(tag);
}
if (visible !== undefined) {
localVarQueryParameter["visible"] = visible;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
@ -653,8 +629,6 @@ export const UcApiContentHaloRunV1alpha1PostApiFp = function (
},
/**
* List posts owned by the current user.
* @param {Array<string>} [category]
* @param {Array<string>} [contributor]
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {string} [keyword] Posts filtered by keyword.
* @param {Array<string>} [labelSelector] Label selector for filtering.
@ -662,14 +636,10 @@ export const UcApiContentHaloRunV1alpha1PostApiFp = function (
* @param {'DRAFT' | 'PENDING_APPROVAL' | 'PUBLISHED' | 'FAILED'} [publishPhase]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,publishTime
* @param {Array<string>} [tag]
* @param {'PUBLIC' | 'INTERNAL' | 'PRIVATE'} [visible]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listMyPosts(
category?: Array<string>,
contributor?: Array<string>,
fieldSelector?: Array<string>,
keyword?: string,
labelSelector?: Array<string>,
@ -677,15 +647,11 @@ export const UcApiContentHaloRunV1alpha1PostApiFp = function (
publishPhase?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED" | "FAILED",
size?: number,
sort?: Array<string>,
tag?: Array<string>,
visible?: "PUBLIC" | "INTERNAL" | "PRIVATE",
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedPostList>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listMyPosts(
category,
contributor,
fieldSelector,
keyword,
labelSelector,
@ -693,8 +659,6 @@ export const UcApiContentHaloRunV1alpha1PostApiFp = function (
publishPhase,
size,
sort,
tag,
visible,
options
);
return createRequestFunction(
@ -875,8 +839,6 @@ export const UcApiContentHaloRunV1alpha1PostApiFactory = function (
): AxiosPromise<ListedPostList> {
return localVarFp
.listMyPosts(
requestParameters.category,
requestParameters.contributor,
requestParameters.fieldSelector,
requestParameters.keyword,
requestParameters.labelSelector,
@ -884,8 +846,6 @@ export const UcApiContentHaloRunV1alpha1PostApiFactory = function (
requestParameters.publishPhase,
requestParameters.size,
requestParameters.sort,
requestParameters.tag,
requestParameters.visible,
options
)
.then((request) => request(axios, basePath));
@ -1008,20 +968,6 @@ export interface UcApiContentHaloRunV1alpha1PostApiGetMyPostDraftRequest {
* @interface UcApiContentHaloRunV1alpha1PostApiListMyPostsRequest
*/
export interface UcApiContentHaloRunV1alpha1PostApiListMyPostsRequest {
/**
*
* @type {Array<string>}
* @memberof UcApiContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly category?: Array<string>;
/**
*
* @type {Array<string>}
* @memberof UcApiContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly contributor?: Array<string>;
/**
* Field selector for filtering.
* @type {Array<string>}
@ -1070,20 +1016,6 @@ export interface UcApiContentHaloRunV1alpha1PostApiListMyPostsRequest {
* @memberof UcApiContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly sort?: Array<string>;
/**
*
* @type {Array<string>}
* @memberof UcApiContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly tag?: Array<string>;
/**
*
* @type {'PUBLIC' | 'INTERNAL' | 'PRIVATE'}
* @memberof UcApiContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly visible?: "PUBLIC" | "INTERNAL" | "PRIVATE";
}
/**
@ -1228,8 +1160,6 @@ export class UcApiContentHaloRunV1alpha1PostApi extends BaseAPI {
) {
return UcApiContentHaloRunV1alpha1PostApiFp(this.configuration)
.listMyPosts(
requestParameters.category,
requestParameters.contributor,
requestParameters.fieldSelector,
requestParameters.keyword,
requestParameters.labelSelector,
@ -1237,8 +1167,6 @@ export class UcApiContentHaloRunV1alpha1PostApi extends BaseAPI {
requestParameters.publishPhase,
requestParameters.size,
requestParameters.sort,
requestParameters.tag,
requestParameters.visible,
options
)
.then((request) => request(this.axios, this.basePath));

View File

@ -34,6 +34,12 @@ export interface PluginStatus {
* @memberof PluginStatus
*/
entry?: string;
/**
*
* @type {string}
* @memberof PluginStatus
*/
lastProbeState?: PluginStatusLastProbeStateEnum;
/**
*
* @type {string}
@ -66,7 +72,7 @@ export interface PluginStatus {
stylesheet?: string;
}
export const PluginStatusPhaseEnum = {
export const PluginStatusLastProbeStateEnum = {
Created: "CREATED",
Disabled: "DISABLED",
Resolved: "RESOLVED",
@ -75,5 +81,19 @@ export const PluginStatusPhaseEnum = {
Failed: "FAILED",
} as const;
export type PluginStatusLastProbeStateEnum =
(typeof PluginStatusLastProbeStateEnum)[keyof typeof PluginStatusLastProbeStateEnum];
export const PluginStatusPhaseEnum = {
Pending: "PENDING",
Starting: "STARTING",
Created: "CREATED",
Disabled: "DISABLED",
Resolved: "RESOLVED",
Started: "STARTED",
Stopped: "STOPPED",
Failed: "FAILED",
Unknown: "UNKNOWN",
} as const;
export type PluginStatusPhaseEnum =
(typeof PluginStatusPhaseEnum)[keyof typeof PluginStatusPhaseEnum];

View File

@ -149,7 +149,9 @@ const handleBuildSearchIndex = () => {
});
apiClient.extension.category
.listcontentHaloRunV1alpha1Category()
.listcontentHaloRunV1alpha1Category({
sort: ["metadata.creationTimestamp,desc"],
})
.then((response) => {
response.data.items.forEach((category) => {
fuse.add({
@ -168,23 +170,27 @@ const handleBuildSearchIndex = () => {
});
});
apiClient.extension.tag.listcontentHaloRunV1alpha1Tag().then((response) => {
response.data.items.forEach((tag) => {
fuse.add({
title: tag.spec.displayName,
icon: {
component: markRaw(IconBookRead),
},
group: t("core.components.global_search.groups.tag"),
route: {
name: "Tags",
query: {
name: tag.metadata.name,
apiClient.extension.tag
.listcontentHaloRunV1alpha1Tag({
sort: ["metadata.creationTimestamp,desc"],
})
.then((response) => {
response.data.items.forEach((tag) => {
fuse.add({
title: tag.spec.displayName,
icon: {
component: markRaw(IconBookRead),
},
},
group: t("core.components.global_search.groups.tag"),
route: {
name: "Tags",
query: {
name: tag.metadata.name,
},
},
});
});
});
});
}
if (currentUserHasPermission(["system:singlepages:view"])) {

View File

@ -15,7 +15,9 @@ declare module "@formkit/inputs" {
function optionsHandler(node: FormKitNode) {
node.on("created", async () => {
const { data } =
await apiClient.extension.category.listcontentHaloRunV1alpha1Category();
await apiClient.extension.category.listcontentHaloRunV1alpha1Category({
sort: ["metadata.creationTimestamp,desc"],
});
node.props.options = data.items.map((category) => {
return {

View File

@ -15,7 +15,9 @@ declare module "@formkit/inputs" {
function optionsHandler(node: FormKitNode) {
node.on("created", async () => {
const { data } =
await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag();
await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({
sort: ["metadata.creationTimestamp,desc"],
});
node.props.options = data.items.map((tag) => {
return {

View File

@ -69,7 +69,7 @@ const {
size.value = data.size;
},
refetchInterval: (data) => {
const abnormalPosts = data?.items.filter((post) => {
const hasAbnormalPost = data?.items.some((post) => {
const { spec, metadata, status } = post.post;
return (
spec.deleted ||
@ -78,7 +78,7 @@ const {
);
});
return abnormalPosts?.length ? 1000 : false;
return hasAbnormalPost ? 1000 : false;
},
});
</script>