refactor: optimize the implementation of indexed query engine through query index view (#5233)

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

#### What this PR does / why we need it:
通过重构 QueryIndexView 的实现方式来优化 IndexedQueryEngine 的逻辑并简化排序过程

how to test it?
单元测试通过即可,此 PR 的修改都是基于单元测试的基础上对原代码做的重构

#### Does this PR introduce a user-facing change?
```release-note
None
```
pull/5245/head
guqing 2024-01-24 10:27:44 +08:00 committed by GitHub
parent 8523a67e06
commit 57fb644173
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 741 additions and 568 deletions

View File

@ -0,0 +1,47 @@
package run.halo.app.extension.index;
import java.util.Comparator;
import org.springframework.lang.Nullable;
public class KeyComparator implements Comparator<String> {
public static final KeyComparator INSTANCE = new KeyComparator();
@Override
public int compare(@Nullable String a, @Nullable String b) {
if (a == null && b == null) {
return 0;
} else if (a == null) {
// null less than everything
return 1;
} else if (b == null) {
// null less than everything
return -1;
}
int i = 0;
int j = 0;
while (i < a.length() && j < b.length()) {
if (Character.isDigit(a.charAt(i)) && Character.isDigit(b.charAt(j))) {
// handle number part
int num1 = 0;
int num2 = 0;
while (i < a.length() && Character.isDigit(a.charAt(i))) {
num1 = num1 * 10 + (a.charAt(i++) - '0');
}
while (j < b.length() && Character.isDigit(b.charAt(j))) {
num2 = num2 * 10 + (b.charAt(j++) - '0');
}
if (num1 != num2) {
return num1 - num2;
}
} else if (a.charAt(i) != b.charAt(j)) {
// handle non-number part
return a.charAt(i) - b.charAt(j);
} else {
i++;
j++;
}
}
return a.length() - b.length();
}
}

View File

@ -25,6 +25,8 @@ public class And extends LogicalQuery {
NavigableSet<String> resultSet = null;
for (Query query : childQueries) {
NavigableSet<String> currentResult = query.matches(indexView);
// Trim unneeded rows to shrink the dataset for the next query
indexView.removeByIdNotIn(currentResult);
if (resultSet == null) {
resultSet = Sets.newTreeSet(currentResult);
} else {

View File

@ -1,6 +1,7 @@
package run.halo.app.extension.index.query;
import java.util.NavigableSet;
import org.springframework.util.Assert;
public class EqualQuery extends SimpleQuery {
@ -10,6 +11,7 @@ public class EqualQuery extends SimpleQuery {
public EqualQuery(String fieldName, String value, boolean isFieldRef) {
super(fieldName, value, isFieldRef);
Assert.notNull(value, "Value must not be null, use IsNull or IsNotNull instead");
}
@Override

View File

@ -0,0 +1,15 @@
package run.halo.app.extension.index.query;
import java.util.NavigableSet;
public class IsNotNull extends SimpleQuery {
protected IsNotNull(String fieldName) {
super(fieldName, null);
}
@Override
public NavigableSet<String> matches(QueryIndexView indexView) {
return indexView.getAllIdsForField(fieldName);
}
}

View File

@ -0,0 +1,18 @@
package run.halo.app.extension.index.query;
import java.util.NavigableSet;
public class IsNull extends SimpleQuery {
protected IsNull(String fieldName) {
super(fieldName, null);
}
@Override
public NavigableSet<String> matches(QueryIndexView indexView) {
var allIds = indexView.getAllIds();
var idsForField = indexView.getAllIdsForField(fieldName);
allIds.removeAll(idsForField);
return allIds;
}
}

View File

@ -2,6 +2,7 @@ package run.halo.app.extension.index.query;
import com.google.common.collect.Sets;
import java.util.NavigableSet;
import org.springframework.util.Assert;
public class NotEqual extends SimpleQuery {
private final EqualQuery equalQuery;
@ -12,6 +13,7 @@ public class NotEqual extends SimpleQuery {
public NotEqual(String fieldName, String value, boolean isFieldRef) {
super(fieldName, value, isFieldRef);
Assert.notNull(value, "Value must not be null, use IsNull or IsNotNull instead");
this.equalQuery = new EqualQuery(fieldName, value, isFieldRef);
}

View File

@ -20,7 +20,21 @@ public class QueryFactory {
return new All(fieldName);
}
public static Query isNull(String fieldName) {
return new IsNull(fieldName);
}
public static Query isNotNull(String fieldName) {
return new IsNotNull(fieldName);
}
/**
* Create a {@link NotEqual} for the given {@code fieldName} and {@code attributeValue}.
*/
public static Query notEqual(String fieldName, String attributeValue) {
if (attributeValue == null) {
return new IsNotNull(fieldName);
}
return new NotEqual(fieldName, attributeValue);
}
@ -28,7 +42,13 @@ public class QueryFactory {
return new NotEqual(fieldName, otherFieldName, true);
}
/**
* Create a {@link EqualQuery} for the given {@code fieldName} and {@code attributeValue}.
*/
public static Query equal(String fieldName, String attributeValue) {
if (attributeValue == null) {
return new IsNull(fieldName);
}
return new EqualQuery(fieldName, attributeValue);
}
@ -73,6 +93,9 @@ public class QueryFactory {
return in(fieldName, Set.of(attributeValues));
}
/**
* Create an {@link InQuery} for the given {@code fieldName} and {@code values}.
*/
public static Query in(String fieldName, Collection<String> values) {
Assert.notNull(values, "Values must not be null");
if (values.size() == 1) {
@ -85,6 +108,9 @@ public class QueryFactory {
return new InQuery(fieldName, valueSet);
}
/**
* Create an {@link And} for the given {@link Query}s.
*/
public static Query and(Collection<Query> queries) {
Assert.notEmpty(queries, "Queries must not be empty");
if (queries.size() == 1) {
@ -98,6 +124,9 @@ public class QueryFactory {
return new And(queries);
}
/**
* Create an {@link And} for the given {@link Query}s.
*/
public static Query and(Query query1, Query query2, Query... additionalQueries) {
var queries = new ArrayList<Query>(2 + additionalQueries.length);
queries.add(query1);
@ -106,6 +135,9 @@ public class QueryFactory {
return new And(queries);
}
/**
* Create an {@link And} for the given {@link Query}s.
*/
public static Query and(Query query1, Query query2, Collection<Query> additionalQueries) {
var queries = new ArrayList<Query>(2 + additionalQueries.size());
queries.add(query1);
@ -119,6 +151,9 @@ public class QueryFactory {
return new Or(queries);
}
/**
* Create an {@link Or} for the given {@link Query}s.
*/
public static Query or(Query query1, Query query2, Query... additionalQueries) {
var queries = new ArrayList<Query>(2 + additionalQueries.length);
queries.add(query1);
@ -127,6 +162,9 @@ public class QueryFactory {
return new Or(queries);
}
/**
* Create an {@link Or} for the given {@link Query}s.
*/
public static Query or(Query query1, Query query2, Collection<Query> additionalQueries) {
var queries = new ArrayList<Query>(2 + additionalQueries.size());
queries.add(query1);

View File

@ -1,6 +1,8 @@
package run.halo.app.extension.index.query;
import java.util.List;
import java.util.NavigableSet;
import org.springframework.data.domain.Sort;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.index.IndexSpec;
@ -37,7 +39,7 @@ public interface QueryIndexView {
NavigableSet<String> getAllValuesForField(String fieldName);
/**
* Gets all object ids for a given field name.
* Gets all object ids for a given field name without null cells.
*
* @param fieldName the field name
* @return all indexed object ids for the given field name
@ -45,6 +47,13 @@ public interface QueryIndexView {
*/
NavigableSet<String> getAllIdsForField(String fieldName);
/**
* Gets all object ids in this view.
*
* @return all object ids in this view
*/
NavigableSet<String> getAllIds();
NavigableSet<String> findIdsForFieldValueEqual(String fieldName1, String fieldName2);
NavigableSet<String> findIdsForFieldValueGreaterThan(String fieldName1, String fieldName2,
@ -53,5 +62,7 @@ public interface QueryIndexView {
NavigableSet<String> findIdsForFieldValueLessThan(String fieldName1, String fieldName2,
boolean orEqual);
void removeAllFieldValuesByIdNotIn(NavigableSet<String> ids);
void removeByIdNotIn(NavigableSet<String> ids);
List<String> sortBy(Sort sort);
}

View File

@ -1,26 +1,35 @@
package run.halo.app.extension.index.query;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Multimaps;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.Sets;
import com.google.common.collect.Table;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NavigableSet;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import run.halo.app.extension.index.KeyComparator;
/**
* A default implementation for {@link QueryIndexView}.
* A default implementation for {@link run.halo.app.extension.index.query.QueryIndexView}.
*
* @author guqing
* @since 2.12.0
*/
public class QueryIndexViewImpl implements QueryIndexView {
private final Lock lock = new ReentrantLock();
private final Map<String, SetMultimap<String, String>> orderedMatches;
private final Set<String> fieldNames;
private final Table<String, String, NavigableSet<String>> orderedMatches;
/**
* Creates a new {@link QueryIndexViewImpl} for the given {@link Map} of index entries.
@ -28,10 +37,21 @@ public class QueryIndexViewImpl implements QueryIndexView {
* @param indexEntries index entries from indexer to create the view for.
*/
public QueryIndexViewImpl(Map<String, Collection<Map.Entry<String, String>>> indexEntries) {
this.orderedMatches = new HashMap<>();
this.fieldNames = new HashSet<>();
this.orderedMatches = HashBasedTable.create();
for (var entry : indexEntries.entrySet()) {
// do not use stream collect here as it is slower
this.orderedMatches.put(entry.getKey(), createSetMultiMap(entry.getValue()));
String fieldName = entry.getKey();
this.fieldNames.add(fieldName);
for (var fieldEntry : entry.getValue()) {
var id = fieldEntry.getValue();
var fieldValue = fieldEntry.getKey();
var columnValue = this.orderedMatches.get(id, fieldName);
if (columnValue == null) {
columnValue = Sets.newTreeSet();
this.orderedMatches.put(id, fieldName, columnValue);
}
columnValue.add(fieldValue);
}
}
}
@ -40,8 +60,13 @@ public class QueryIndexViewImpl implements QueryIndexView {
lock.lock();
try {
checkFieldNameIndexed(fieldName);
SetMultimap<String, String> fieldMap = orderedMatches.get(fieldName);
return fieldMap != null ? new TreeSet<>(fieldMap.get(fieldValue)) : emptySet();
var result = new TreeSet<String>();
for (var cell : orderedMatches.cellSet()) {
if (cell.getColumnKey().equals(fieldName) && cell.getValue().contains(fieldValue)) {
result.add(cell.getRowKey());
}
}
return result;
} finally {
lock.unlock();
}
@ -52,8 +77,13 @@ public class QueryIndexViewImpl implements QueryIndexView {
lock.lock();
try {
checkFieldNameIndexed(fieldName);
SetMultimap<String, String> fieldMap = orderedMatches.get(fieldName);
return fieldMap != null ? new TreeSet<>(fieldMap.keySet()) : emptySet();
var result = Sets.<String>newTreeSet();
for (var cell : orderedMatches.cellSet()) {
if (cell.getColumnKey().equals(fieldName)) {
result.addAll(cell.getValue());
}
}
return result;
} finally {
lock.unlock();
}
@ -64,8 +94,24 @@ public class QueryIndexViewImpl implements QueryIndexView {
lock.lock();
try {
checkFieldNameIndexed(fieldName);
SetMultimap<String, String> fieldMap = orderedMatches.get(fieldName);
return fieldMap != null ? new TreeSet<>(fieldMap.values()) : emptySet();
NavigableSet<String> ids = new TreeSet<>();
// iterate over the table and collect all IDs associated with the given field name
for (var cell : orderedMatches.cellSet()) {
if (cell.getColumnKey().equals(fieldName)) {
ids.add(cell.getRowKey());
}
}
return ids;
} finally {
lock.unlock();
}
}
@Override
public NavigableSet<String> getAllIds() {
lock.lock();
try {
return new TreeSet<>(orderedMatches.rowKeySet());
} finally {
lock.unlock();
}
@ -77,17 +123,25 @@ public class QueryIndexViewImpl implements QueryIndexView {
try {
checkFieldNameIndexed(fieldName1);
checkFieldNameIndexed(fieldName2);
var index1 = orderedMatches.get(fieldName1);
var index2 = orderedMatches.get(fieldName2);
var idFieldValuesForIndex2Map =
Multimaps.invertFrom(index2, MultimapBuilder.treeKeys().treeSetValues().build());
var result = Sets.<String>newTreeSet();
for (Map.Entry<String, String> entryForIndex1 : index1.entries()) {
var fieldValues = idFieldValuesForIndex2Map.get(entryForIndex1.getValue());
for (String item : fieldValues) {
if (entryForIndex1.getKey().equals(item)) {
result.add(entryForIndex1.getValue());
NavigableSet<String> result = new TreeSet<>();
// obtain all values for both fields and their corresponding IDs
var field1ValuesToIds = getColumnValuesToIdsMap(fieldName1);
var field2ValuesToIds = getColumnValuesToIdsMap(fieldName2);
// iterate over each value of the first field
for (Map.Entry<String, NavigableSet<String>> entry : field1ValuesToIds.entrySet()) {
String fieldValue = entry.getKey();
NavigableSet<String> idsForFieldValue = entry.getValue();
// if the second field contains the same value, add all matching IDs
if (field2ValuesToIds.containsKey(fieldValue)) {
NavigableSet<String> matchingIds = field2ValuesToIds.get(fieldValue);
for (String id : idsForFieldValue) {
if (matchingIds.contains(id)) {
result.add(id);
}
}
}
}
@ -97,6 +151,29 @@ public class QueryIndexViewImpl implements QueryIndexView {
}
}
private Map<String, NavigableSet<String>> getColumnValuesToIdsMap(String fieldName) {
var valuesToIdsMap = new HashMap<String, NavigableSet<String>>();
for (var cell : orderedMatches.cellSet()) {
if (cell.getColumnKey().equals(fieldName)) {
var celValues = cell.getValue();
if (CollectionUtils.isEmpty(celValues)) {
continue;
}
if (celValues.size() != 1) {
throw new IllegalArgumentException(
"Unsupported multi cell values to join with other field for: " + fieldName
+ " with values: " + celValues);
}
String fieldValue = cell.getValue().first();
if (!valuesToIdsMap.containsKey(fieldValue)) {
valuesToIdsMap.put(fieldValue, new TreeSet<>());
}
valuesToIdsMap.get(fieldValue).add(cell.getRowKey());
}
}
return valuesToIdsMap;
}
@Override
public NavigableSet<String> findIdsForFieldValueGreaterThan(String fieldName1,
String fieldName2, boolean orEqual) {
@ -105,19 +182,28 @@ public class QueryIndexViewImpl implements QueryIndexView {
checkFieldNameIndexed(fieldName1);
checkFieldNameIndexed(fieldName2);
var index1 = orderedMatches.get(fieldName1);
var index2 = orderedMatches.get(fieldName2);
NavigableSet<String> result = new TreeSet<>();
var idFieldValuesForIndex2Map =
Multimaps.invertFrom(index2, MultimapBuilder.treeKeys().treeSetValues().build());
// obtain all values for both fields and their corresponding IDs
var field1ValuesToIds = getColumnValuesToIdsMap(fieldName1);
var field2ValuesToIds = getColumnValuesToIdsMap(fieldName2);
var result = Sets.<String>newTreeSet();
for (Map.Entry<String, String> entryForIndex1 : index1.entries()) {
var fieldValues = idFieldValuesForIndex2Map.get(entryForIndex1.getValue());
for (String item : fieldValues) {
int compare = entryForIndex1.getKey().compareTo(item);
if (orEqual ? compare >= 0 : compare > 0) {
result.add(entryForIndex1.getValue());
// iterate over each value of the first field
for (var entryField1 : field1ValuesToIds.entrySet()) {
String fieldValue1 = entryField1.getKey();
// iterate over each value of the second field
for (var entryField2 : field2ValuesToIds.entrySet()) {
String fieldValue2 = entryField2.getKey();
int comparison = fieldValue1.compareTo(fieldValue2);
if (orEqual ? comparison >= 0 : comparison > 0) {
// if the second field contains the same value, add all matching IDs
for (String id : entryField1.getValue()) {
if (field2ValuesToIds.get(fieldValue2).contains(id)) {
result.add(id);
}
}
}
}
}
@ -134,19 +220,29 @@ public class QueryIndexViewImpl implements QueryIndexView {
try {
checkFieldNameIndexed(fieldName1);
checkFieldNameIndexed(fieldName2);
SetMultimap<String, String> index1 = orderedMatches.get(fieldName1);
SetMultimap<String, String> index2 = orderedMatches.get(fieldName2);
var idFieldValuesForIndex2Map =
Multimaps.invertFrom(index2, MultimapBuilder.treeKeys().treeSetValues().build());
NavigableSet<String> result = new TreeSet<>();
var result = Sets.<String>newTreeSet();
for (Map.Entry<String, String> entryForIndex1 : index1.entries()) {
var fieldValues = idFieldValuesForIndex2Map.get(entryForIndex1.getValue());
for (String item : fieldValues) {
int compare = entryForIndex1.getKey().compareTo(item);
if (orEqual ? compare <= 0 : compare < 0) {
result.add(entryForIndex1.getValue());
// obtain all values for both fields and their corresponding IDs
var field1ValuesToIds = getColumnValuesToIdsMap(fieldName1);
var field2ValuesToIds = getColumnValuesToIdsMap(fieldName2);
// iterate over each value of the first field
for (var entryField1 : field1ValuesToIds.entrySet()) {
String fieldValue1 = entryField1.getKey();
// iterate over each value of the second field
for (var entryField2 : field2ValuesToIds.entrySet()) {
String fieldValue2 = entryField2.getKey();
int comparison = fieldValue1.compareTo(fieldValue2);
if (orEqual ? comparison <= 0 : comparison < 0) {
// if the second field contains the same value, add all matching IDs
for (String id : entryField1.getValue()) {
if (field2ValuesToIds.get(fieldValue2).contains(id)) {
result.add(id);
}
}
}
}
}
@ -157,38 +253,100 @@ public class QueryIndexViewImpl implements QueryIndexView {
}
@Override
public void removeAllFieldValuesByIdNotIn(NavigableSet<String> ids) {
public void removeByIdNotIn(NavigableSet<String> ids) {
lock.lock();
try {
for (var fieldNameValuesEntry : orderedMatches.entrySet()) {
SetMultimap<String, String> indicates = fieldNameValuesEntry.getValue();
indicates.entries().removeIf(entry -> !ids.contains(entry.getValue()));
Set<String> idsToRemove = new HashSet<>();
// check each row key if it is not in the given ids set
for (String rowKey : orderedMatches.rowKeySet()) {
if (!ids.contains(rowKey)) {
idsToRemove.add(rowKey);
}
}
// remove all rows that are not in the given ids set
for (String idToRemove : idsToRemove) {
orderedMatches.row(idToRemove).clear();
}
} finally {
lock.unlock();
}
}
@Override
public List<String> sortBy(Sort sort) {
lock.lock();
try {
for (Sort.Order order : sort) {
String fieldName = order.getProperty();
checkFieldNameIndexed(fieldName);
}
// obtain all row keys (IDs)
Set<String> allRowKeys = orderedMatches.rowKeySet();
// convert row keys to list for sorting
List<String> sortedRowKeys = new ArrayList<>(allRowKeys);
if (sort.isUnsorted()) {
return sortedRowKeys;
}
// sort row keys according to sort criteria in a Sort object
sortedRowKeys.sort((id1, id2) -> {
for (Sort.Order order : sort) {
String fieldName = order.getProperty();
// compare the values of the two rows on the field
int comparison = compareRowValue(id1, id2, fieldName, order.isAscending());
if (comparison != 0) {
return comparison;
}
}
// if all sort criteria are equal, return 0
return 0;
});
return sortedRowKeys;
} finally {
lock.unlock();
}
}
private int compareRowValue(String id1, String id2, String fieldName, boolean isAscending) {
var value1 = getSingleFieldValueForSort(id1, fieldName);
var value2 = getSingleFieldValueForSort(id2, fieldName);
// nulls are less than everything whatever the sort order is
// do not simply the following code for null check,it's different from KeyComparator
if (value1 == null && value2 == null) {
return 0;
} else if (value1 == null) {
return 1;
} else if (value2 == null) {
return -1;
}
return isAscending ? KeyComparator.INSTANCE.compare(value1, value2)
: KeyComparator.INSTANCE.compare(value2, value1);
}
@Nullable
String getSingleFieldValueForSort(String rowKey, String fieldName) {
return Optional.ofNullable(orderedMatches.get(rowKey, fieldName))
.filter(values -> !CollectionUtils.isEmpty(values))
.map(values -> {
if (values.size() != 1) {
throw new IllegalArgumentException(
"Unsupported multi field values to sort for: " + fieldName
+ " with values: " + values);
}
return values.first();
})
.orElse(null);
}
private void checkFieldNameIndexed(String fieldName) {
if (!orderedMatches.containsKey(fieldName)) {
if (!fieldNames.contains(fieldName)) {
throw new IllegalArgumentException("Field name " + fieldName
+ " is not indexed, please ensure it added to the index spec before querying");
}
}
private TreeSet<String> emptySet() {
return new TreeSet<>();
}
private SetMultimap<String, String> createSetMultiMap(
Collection<Map.Entry<String, String>> entries) {
SetMultimap<String, String> multiMap = MultimapBuilder.hashKeys()
.hashSetValues()
.build();
for (Map.Entry<String, String> entry : entries) {
multiMap.put(entry.getKey(), entry.getValue());
}
return multiMap;
}
}

View File

@ -0,0 +1,79 @@
package run.halo.app.extension.index;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.Comparator;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link KeyComparator}.
*
* @author guqing
* @since 2.12.0
*/
class KeyComparatorTest {
@Test
void keyComparator() {
var comparator = KeyComparator.INSTANCE;
String[] strings = {"103", "101", "102", "1011", "1013", "1021", "1022", "1012", "1023"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(
new String[] {"101", "102", "103", "1011", "1012", "1013", "1021", "1022", "1023"});
Arrays.sort(strings, comparator.reversed());
assertThat(strings).isEqualTo(
new String[] {"1023", "1022", "1021", "1013", "1012", "1011", "103", "102", "101"});
// but if we use natural order, the result is:
Arrays.sort(strings, Comparator.naturalOrder());
assertThat(strings).isEqualTo(
new String[] {"101", "1011", "1012", "1013", "102", "1021", "1022", "1023", "103"});
}
@Test
void keyComparator2() {
var comparator = KeyComparator.INSTANCE;
String[] strings =
{"moment-101", "moment-102", "moment-103", "moment-1011", "moment-1013", "moment-1021",
"moment-1022", "moment-1012", "moment-1023"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"moment-101", "moment-102", "moment-103",
"moment-1011", "moment-1012", "moment-1013", "moment-1021", "moment-1022",
"moment-1023"});
// date sort
strings =
new String[] {"2022-01-15", "2022-02-01", "2021-12-25", "2022-01-01", "2022-01-02"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(
new String[] {"2021-12-25", "2022-01-01", "2022-01-02", "2022-01-15", "2022-02-01"});
// alphabet and number sort
strings = new String[] {"abc123", "abc45", "abc9", "abc100", "abc20"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(
new String[] {"abc9", "abc20", "abc45", "abc100", "abc123"});
// test for pure alphabet sort
strings = new String[] {"xyz", "abc", "def", "abcde", "xyzabc"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"abc", "abcde", "def", "xyz", "xyzabc"});
// test for empty string
strings = new String[] {"", "abc", "123", "xyz"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"", "123", "abc", "xyz"});
// test for the same string
strings = new String[] {"abc", "abc", "abc", "abc"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"abc", "abc", "abc", "abc"});
// test for null element
strings = new String[] {null, "abc", "123", "xyz"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"123", "abc", "xyz", null});
}
}

View File

@ -36,16 +36,18 @@ public class AndTest {
Map.entry("17", "johnniang")
);
var entries = Map.of("dept", deptEntry, "age", ageEntry);
var indexView = new QueryIndexViewImpl(entries);
var indexView = new QueryIndexViewImpl(entries);
var query = and(equal("dept", "B"), equal("age", "18"));
var resultSet = query.matches(indexView);
assertThat(resultSet).containsExactly("zhangsan");
indexView = new QueryIndexViewImpl(entries);
query = and(equal("dept", "C"), equal("age", "18"));
resultSet = query.matches(indexView);
assertThat(resultSet).isEmpty();
indexView = new QueryIndexViewImpl(entries);
query = and(
// guqing, halo, lisi, zhangsan
or(equal("dept", "A"), equal("dept", "B")),
@ -55,6 +57,7 @@ public class AndTest {
resultSet = query.matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder("guqing", "halo", "zhangsan");
indexView = new QueryIndexViewImpl(entries);
query = and(
// guqing, halo, lisi, zhangsan
or(equal("dept", "A"), equal("dept", "B")),
@ -64,6 +67,7 @@ public class AndTest {
resultSet = query.matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder("guqing", "halo", "zhangsan");
indexView = new QueryIndexViewImpl(entries);
query = and(
// guqing, halo, lisi, zhangsan
or(equal("dept", "A"), equal("dept", "C")),
@ -76,7 +80,7 @@ public class AndTest {
@Test
void andMatch2() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var query = and(equal("lastName", "Fay"),
and(
equal("hireDate", "17"),

View File

@ -4,7 +4,7 @@ import java.util.Collection;
import java.util.List;
import java.util.Map;
public class EmployeeDataSet {
public class IndexViewDataSet {
/**
* Create a {@link QueryIndexView} for employee to test.
@ -95,4 +95,82 @@ public class EmployeeDataSet {
"departmentId", departmentIdEntry);
return new QueryIndexViewImpl(entries);
}
/**
* Create a {@link QueryIndexView} for post to test.
*
* @return a {@link QueryIndexView} for post to test
*/
public static QueryIndexView createPostIndexViewWithNullCell() {
/*
* id title published publishTime owner
* 100 title1 true 2024-01-01T00:00:00 jack
* 101 title2 true 2024-01-02T00:00:00 rose
* 102 title3 false null smith
* 103 title4 false null peter
* 104 title5 false null john
* 105 title6 true 2024-01-05 00:00:00 tom
* 106 title7 true 2024-01-05 13:00:00 jerry
* 107 title8 true 2024-01-05 12:00:00 jerry
* 108 title9 false null jerry
*/
Collection<Map.Entry<String, String>> idEntry = List.of(
Map.entry("100", "100"),
Map.entry("101", "101"),
Map.entry("102", "102"),
Map.entry("103", "103"),
Map.entry("104", "104"),
Map.entry("105", "105"),
Map.entry("106", "106"),
Map.entry("107", "107"),
Map.entry("108", "108")
);
Collection<Map.Entry<String, String>> titleEntry = List.of(
Map.entry("title1", "100"),
Map.entry("title2", "101"),
Map.entry("title3", "102"),
Map.entry("title4", "103"),
Map.entry("title5", "104"),
Map.entry("title6", "105"),
Map.entry("title7", "106"),
Map.entry("title8", "107"),
Map.entry("title9", "108")
);
Collection<Map.Entry<String, String>> publishedEntry = List.of(
Map.entry("true", "100"),
Map.entry("true", "101"),
Map.entry("false", "102"),
Map.entry("false", "103"),
Map.entry("false", "104"),
Map.entry("true", "105"),
Map.entry("true", "106"),
Map.entry("true", "107"),
Map.entry("false", "108")
);
Collection<Map.Entry<String, String>> publishTimeEntry = List.of(
Map.entry("2024-01-01T00:00:00", "100"),
Map.entry("2024-01-02T00:00:00", "101"),
Map.entry("2024-01-05 00:00:00", "105"),
Map.entry("2024-01-05 13:00:00", "106"),
Map.entry("2024-01-05 12:00:00", "107")
);
Collection<Map.Entry<String, String>> ownerEntry = List.of(
Map.entry("jack", "100"),
Map.entry("rose", "101"),
Map.entry("smith", "102"),
Map.entry("peter", "103"),
Map.entry("john", "104"),
Map.entry("tom", "105"),
Map.entry("jerry", "106"),
Map.entry("jerry", "107"),
Map.entry("jerry", "108")
);
var entries = Map.of("id", idEntry,
"title", titleEntry,
"published", publishedEntry,
"publishTime", publishTimeEntry,
"owner", ownerEntry);
return new QueryIndexViewImpl(entries);
}
}

View File

@ -18,19 +18,36 @@ import org.junit.jupiter.api.Test;
*/
class QueryFactoryTest {
@Test
void allTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = all("firstName").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "102", "103", "104", "105"
);
}
@Test
void isNullTest() {
var indexView = IndexViewDataSet.createPostIndexViewWithNullCell();
var resultSet = QueryFactory.isNull("publishTime").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"102", "103", "104", "108"
);
}
@Test
void isNotNullTest() {
var indexView = IndexViewDataSet.createPostIndexViewWithNullCell();
var resultSet = QueryFactory.isNotNull("publishTime").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "105", "106", "107"
);
}
@Test
void equalTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = equal("lastName", "Fay").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "104", "105"
@ -39,7 +56,7 @@ class QueryFactoryTest {
@Test
void equalOtherFieldTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = equalOtherField("managerId", "id").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"102", "103"
@ -48,7 +65,7 @@ class QueryFactoryTest {
@Test
void notEqualTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = notEqual("lastName", "Fay").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"101", "102", "103"
@ -57,7 +74,7 @@ class QueryFactoryTest {
@Test
void notEqualOtherFieldTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = notEqualOtherField("managerId", "id").matches(indexView);
// 103 102 is equal
assertThat(resultSet).containsExactlyInAnyOrder(
@ -67,7 +84,7 @@ class QueryFactoryTest {
@Test
void lessThanTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.lessThan("id", "103").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "102"
@ -76,7 +93,7 @@ class QueryFactoryTest {
@Test
void lessThanOtherFieldTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.lessThanOtherField("id", "managerId").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101"
@ -85,7 +102,7 @@ class QueryFactoryTest {
@Test
void lessThanOrEqualTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.lessThanOrEqual("id", "103").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "102", "103"
@ -94,7 +111,7 @@ class QueryFactoryTest {
@Test
void lessThanOrEqualOtherFieldTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet =
QueryFactory.lessThanOrEqualOtherField("id", "managerId").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
@ -104,7 +121,7 @@ class QueryFactoryTest {
@Test
void greaterThanTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.greaterThan("id", "103").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"104", "105"
@ -113,7 +130,7 @@ class QueryFactoryTest {
@Test
void greaterThanOtherFieldTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.greaterThanOtherField("id", "managerId").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"104", "105"
@ -122,7 +139,7 @@ class QueryFactoryTest {
@Test
void greaterThanOrEqualTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.greaterThanOrEqual("id", "103").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"103", "104", "105"
@ -131,7 +148,7 @@ class QueryFactoryTest {
@Test
void greaterThanOrEqualOtherFieldTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet =
QueryFactory.greaterThanOrEqualOtherField("id", "managerId").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
@ -141,7 +158,7 @@ class QueryFactoryTest {
@Test
void inTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.in("id", "103", "104").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"103", "104"
@ -150,7 +167,7 @@ class QueryFactoryTest {
@Test
void inTest2() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.in("lastName", "Fay").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "104", "105"
@ -159,13 +176,13 @@ class QueryFactoryTest {
@Test
void betweenTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = between("id", "103", "105").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"103", "104", "105"
);
indexView = EmployeeDataSet.createEmployeeIndexView();
indexView = IndexViewDataSet.createEmployeeIndexView();
resultSet = between("salary", "2000", "2400").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"101", "102", "103"
@ -174,7 +191,7 @@ class QueryFactoryTest {
@Test
void betweenLowerExclusive() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet =
QueryFactory.betweenLowerExclusive("salary", "2000", "2400").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
@ -184,7 +201,7 @@ class QueryFactoryTest {
@Test
void betweenUpperExclusive() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet =
QueryFactory.betweenUpperExclusive("salary", "2000", "2400").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
@ -194,7 +211,7 @@ class QueryFactoryTest {
@Test
void betweenExclusive() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.betweenExclusive("salary", "2000", "2400").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"102"
@ -203,7 +220,7 @@ class QueryFactoryTest {
@Test
void startsWithTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.startsWith("firstName", "W").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"102"
@ -212,7 +229,7 @@ class QueryFactoryTest {
@Test
void endsWithTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.endsWith("firstName", "y").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"103"
@ -221,7 +238,7 @@ class QueryFactoryTest {
@Test
void containsTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = QueryFactory.contains("firstName", "i").matches(indexView);
assertThat(resultSet).containsExactlyInAnyOrder(
"102"
@ -231,4 +248,4 @@ class QueryFactoryTest {
"104", "105"
);
}
}
}

View File

@ -1,8 +1,16 @@
package run.halo.app.extension.index.query;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Sort;
/**
* Tests for {@link QueryIndexViewImpl}.
@ -12,9 +20,23 @@ import org.junit.jupiter.api.Test;
*/
class QueryIndexViewImplTest {
@Test
void getAllIdsForFieldTest() {
var indexView = IndexViewDataSet.createPostIndexViewWithNullCell();
var resultSet = indexView.getAllIdsForField("title");
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "102", "103", "104", "105", "106", "107", "108"
);
resultSet = indexView.getAllIdsForField("publishTime");
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "105", "106", "107"
);
}
@Test
void findIdsForFieldValueEqualTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = indexView.findIdsForFieldValueEqual("managerId", "id");
assertThat(resultSet).containsExactlyInAnyOrder(
"102", "103"
@ -23,13 +45,13 @@ class QueryIndexViewImplTest {
@Test
void findIdsForFieldValueGreaterThanTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = indexView.findIdsForFieldValueGreaterThan("id", "managerId", false);
assertThat(resultSet).containsExactlyInAnyOrder(
"104", "105"
);
indexView = EmployeeDataSet.createEmployeeIndexView();
indexView = IndexViewDataSet.createEmployeeIndexView();
resultSet = indexView.findIdsForFieldValueGreaterThan("id", "managerId", true);
assertThat(resultSet).containsExactlyInAnyOrder(
"103", "102", "104", "105"
@ -38,13 +60,13 @@ class QueryIndexViewImplTest {
@Test
void findIdsForFieldValueGreaterThanTest2() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = indexView.findIdsForFieldValueGreaterThan("managerId", "id", false);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101"
);
indexView = EmployeeDataSet.createEmployeeIndexView();
indexView = IndexViewDataSet.createEmployeeIndexView();
resultSet = indexView.findIdsForFieldValueGreaterThan("managerId", "id", true);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "102", "103"
@ -53,13 +75,13 @@ class QueryIndexViewImplTest {
@Test
void findIdsForFieldValueLessThanTest() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = indexView.findIdsForFieldValueLessThan("id", "managerId", false);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101"
);
indexView = EmployeeDataSet.createEmployeeIndexView();
indexView = IndexViewDataSet.createEmployeeIndexView();
resultSet = indexView.findIdsForFieldValueLessThan("id", "managerId", true);
assertThat(resultSet).containsExactlyInAnyOrder(
"100", "101", "102", "103"
@ -68,16 +90,129 @@ class QueryIndexViewImplTest {
@Test
void findIdsForFieldValueLessThanTest2() {
var indexView = EmployeeDataSet.createEmployeeIndexView();
var indexView = IndexViewDataSet.createEmployeeIndexView();
var resultSet = indexView.findIdsForFieldValueLessThan("managerId", "id", false);
assertThat(resultSet).containsExactlyInAnyOrder(
"104", "105"
);
indexView = EmployeeDataSet.createEmployeeIndexView();
indexView = IndexViewDataSet.createEmployeeIndexView();
resultSet = indexView.findIdsForFieldValueLessThan("managerId", "id", true);
assertThat(resultSet).containsExactlyInAnyOrder(
"103", "102", "104", "105"
);
}
@Nested
class SortTest {
@Test
void testSortByUnsorted() {
Collection<Map.Entry<String, String>> entries = List.of(
Map.entry("Item1", "Item1"),
Map.entry("Item2", "Item2")
);
var indexView = new QueryIndexViewImpl(Map.of("field1", entries));
var sort = Sort.unsorted();
List<String> sortedList = indexView.sortBy(sort);
assertThat(sortedList).isEqualTo(List.of("Item1", "Item2"));
}
@Test
void testSortBySortedAscending() {
var indexEntries = new HashMap<String, Collection<Map.Entry<String, String>>>();
indexEntries.put("field1",
List.of(Map.entry("key2", "Item2"), Map.entry("key1", "Item1")));
var indexView = new QueryIndexViewImpl(indexEntries);
var sort = Sort.by(Sort.Order.asc("field1"));
List<String> sortedList = indexView.sortBy(sort);
assertThat(sortedList).containsExactly("Item1", "Item2");
}
@Test
void testSortBySortedDescending() {
var indexEntries = new HashMap<String, Collection<Map.Entry<String, String>>>();
indexEntries.put("field1",
List.of(Map.entry("key1", "Item1"), Map.entry("key2", "Item2")));
var indexView = new QueryIndexViewImpl(indexEntries);
var sort = Sort.by(Sort.Order.desc("field1"));
List<String> sortedList = indexView.sortBy(sort);
assertThat(sortedList).containsExactly("Item2", "Item1");
}
@Test
void testSortByMultipleFields() {
var indexEntries = new LinkedHashMap<String, Collection<Map.Entry<String, String>>>();
indexEntries.put("field1", List.of(Map.entry("k3", "Item3"), Map.entry("k2", "Item2")));
indexEntries.put("field2", List.of(Map.entry("k1", "Item1"), Map.entry("k3", "Item3")));
var indexView = new QueryIndexViewImpl(indexEntries);
var sort = Sort.by(Sort.Order.asc("field1"), Sort.Order.desc("field2"));
List<String> sortedList = indexView.sortBy(sort);
assertThat(sortedList).containsExactly("Item2", "Item3", "Item1");
}
@Test
void testSortByWithMissingFieldInMap() {
var indexEntries = new LinkedHashMap<String, Collection<Map.Entry<String, String>>>();
var indexView = new QueryIndexViewImpl(indexEntries);
var sort = Sort.by(Sort.Order.asc("missingField"));
assertThatThrownBy(() -> indexView.sortBy(sort))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Field name missingField is not indexed");
}
@Test
void testSortByMultipleFields2() {
var indexEntries = new LinkedHashMap<String, Collection<Map.Entry<String, String>>>();
var entry1 = List.of(Map.entry("John", "John"),
Map.entry("Bob", "Bob"),
Map.entry("Alice", "Alice")
);
var entry2 = List.of(Map.entry("David", "David"),
Map.entry("Eva", "Eva"),
Map.entry("Frank", "Frank")
);
var entry3 = List.of(Map.entry("George", "George"),
Map.entry("Helen", "Helen"),
Map.entry("Ivy", "Ivy")
);
indexEntries.put("field1", entry1);
indexEntries.put("field2", entry2);
indexEntries.put("field3", entry3);
/*
* <pre>
* Row Key | field1 | field2 | field3
* -------|-------|-------|-------
* John | John | |
* Bob | Bob | |
* Alice | Alice | |
* David | | David |
* Eva | | Eva |
* Frank | | Frank |
* George | | | George
* Helen | | | Helen
* Ivy | | | Ivy
* </pre>
*/
var indexView = new QueryIndexViewImpl(indexEntries);
// "John", "Bob", "Alice", "David", "Eva", "Frank", "George", "Helen", "Ivy"
var sort = Sort.by(Sort.Order.desc("field1"), Sort.Order.asc("field2"),
Sort.Order.asc("field3"));
List<String> sortedList = indexView.sortBy(sort);
assertThat(sortedList).containsSequence("John", "Bob", "Alice", "David", "Eva", "Frank",
"George", "Helen", "Ivy");
}
}
}

View File

@ -1,6 +1,5 @@
package run.halo.app.extension.index;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import java.util.Collection;
@ -42,39 +41,6 @@ public class IndexEntryImpl implements IndexEntry {
return KeyComparator.INSTANCE.reversed();
}
static class KeyComparator implements Comparator<String> {
public static final KeyComparator INSTANCE = new KeyComparator();
@Override
public int compare(String a, String b) {
int i = 0;
int j = 0;
while (i < a.length() && j < b.length()) {
if (Character.isDigit(a.charAt(i)) && Character.isDigit(b.charAt(j))) {
// handle number part
int num1 = 0;
int num2 = 0;
while (i < a.length() && Character.isDigit(a.charAt(i))) {
num1 = num1 * 10 + (a.charAt(i++) - '0');
}
while (j < b.length() && Character.isDigit(b.charAt(j))) {
num2 = num2 * 10 + (b.charAt(j++) - '0');
}
if (num1 != num2) {
return num1 - num2;
}
} else if (a.charAt(i) != b.charAt(j)) {
// handle non-number part
return a.charAt(i) - b.charAt(j);
} else {
i++;
j++;
}
}
return a.length() - b.length();
}
}
@Override
public void acquireReadLock() {
this.rwl.readLock().lock();
@ -151,7 +117,10 @@ public class IndexEntryImpl implements IndexEntry {
public Collection<Map.Entry<String, String>> immutableEntries() {
readLock.lock();
try {
return ImmutableListMultimap.copyOf(indexKeyObjectNamesMap).entries();
// Copy to a new list to avoid ConcurrentModificationException
return indexKeyObjectNamesMap.entries().stream()
.map(entry -> Map.entry(entry.getKey(), entry.getValue()))
.toList();
} finally {
readLock.unlock();
}

View File

@ -2,20 +2,17 @@ package run.halo.app.extension.index;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Sort;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StopWatch;
@ -23,7 +20,7 @@ import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.index.query.All;
import run.halo.app.extension.index.query.QueryIndexViewImpl;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.extension.router.selector.LabelSelector;
@ -134,8 +131,9 @@ public class IndexedQueryEngineImpl implements IndexedQueryEngine {
StopWatch stopWatch = new StopWatch();
stopWatch.start("build index entry map");
var fieldPathEntryMap = fieldPathIndexEntryMap(indexer);
stopWatch.stop();
var primaryEntry = getIndexEntry(PrimaryKeySpecUtils.PRIMARY_INDEX_NAME, fieldPathEntryMap);
stopWatch.stop();
// O(n) time complexity
stopWatch.start("retrieve all metadata names");
var allMetadataNames = new ArrayList<String>();
@ -147,36 +145,38 @@ public class IndexedQueryEngineImpl implements IndexedQueryEngine {
}
stopWatch.stop();
stopWatch.start("build index view");
var indexViewMap = new HashMap<String, Collection<Map.Entry<String, String>>>();
for (Map.Entry<String, IndexEntry> entry : fieldPathEntryMap.entrySet()) {
indexViewMap.put(entry.getKey(), entry.getValue().immutableEntries());
}
// TODO optimize build indexView time
var indexView = new QueryIndexViewImpl(indexViewMap);
stopWatch.stop();
stopWatch.start("retrieve matched metadata names");
var hasLabelSelector = hasLabelSelector(options.getLabelSelector());
final List<String> matchedByLabels = hasLabelSelector
? retrieveForLabelMatchers(options.getLabelSelector().getMatchers(), fieldPathEntryMap,
allMetadataNames)
: allMetadataNames;
indexView.removeByIdNotIn(new TreeSet<>(matchedByLabels));
stopWatch.stop();
stopWatch.start("retrieve matched metadata names by fields");
final var hasFieldSelector = hasFieldSelector(options.getFieldSelector());
var matchedByFields = hasFieldSelector
? retrieveForFieldSelector(options.getFieldSelector().query(), fieldPathEntryMap)
: allMetadataNames;
stopWatch.stop();
stopWatch.start("merge result");
List<String> foundObjectKeys;
if (!hasLabelSelector && !hasFieldSelector) {
foundObjectKeys = allMetadataNames;
} else if (!hasLabelSelector) {
foundObjectKeys = matchedByFields;
} else {
foundObjectKeys = intersection(matchedByFields, matchedByLabels);
if (hasFieldSelector) {
var fieldSelector = options.getFieldSelector();
var query = fieldSelector.query();
var resultSet = query.matches(indexView);
indexView.removeByIdNotIn(resultSet);
}
stopWatch.stop();
stopWatch.start("sort result");
ResultSorter resultSorter = new ResultSorter(fieldPathEntryMap, foundObjectKeys);
var result = resultSorter.sortBy(sort);
var result = indexView.sortBy(sort);
stopWatch.stop();
if (log.isTraceEnabled()) {
log.trace("Retrieve result from indexer, {}", stopWatch.prettyPrint());
}
@ -188,109 +188,8 @@ public class IndexedQueryEngineImpl implements IndexedQueryEngine {
}
boolean hasFieldSelector(FieldSelector fieldSelector) {
return fieldSelector != null && fieldSelector.query() != null;
}
List<String> retrieveForFieldSelector(Query query, Map<String, IndexEntry> fieldPathEntryMap) {
Map<String, Collection<Map.Entry<String, String>>> indexView = new HashMap<>();
for (Map.Entry<String, IndexEntry> entry : fieldPathEntryMap.entrySet()) {
indexView.put(entry.getKey(), entry.getValue().immutableEntries());
}
// TODO optimize build indexView time
var queryIndexView = new QueryIndexViewImpl(indexView);
var resultSet = query.matches(queryIndexView);
return new ArrayList<>(resultSet);
}
/**
* Sort the given list by the given {@link Sort}.
*/
static class ResultSorter {
private final Map<String, IndexEntry> fieldPathEntryMap;
private final List<String> list;
public ResultSorter(Map<String, IndexEntry> fieldPathEntryMap, List<String> list) {
this.fieldPathEntryMap = fieldPathEntryMap;
this.list = list;
}
public List<String> sortBy(@NonNull Sort sort) {
if (sort.isUnsorted()) {
return list;
}
var sortedLists = new ArrayList<List<String>>();
for (Sort.Order order : sort) {
var indexEntry = fieldPathEntryMap.get(order.getProperty());
if (indexEntry == null) {
throwNotIndexedException(order.getProperty());
}
var set = new HashSet<>(list);
var objectNames = new ArrayList<String>();
indexEntry.acquireReadLock();
try {
for (var entry : indexEntry.entries()) {
var objectName = entry.getValue();
if (set.contains(objectName)) {
objectNames.add(objectName);
}
}
} finally {
indexEntry.releaseReadLock();
}
var indexOrder = indexEntry.getIndexDescriptor().getSpec().getOrder();
var asc = IndexSpec.OrderType.ASC.equals(indexOrder);
if (asc != order.isAscending()) {
Collections.reverse(objectNames);
}
sortedLists.add(objectNames);
}
return mergeSortedLists(sortedLists);
}
/**
* <p>Merge the given sorted lists into one sorted list.</p>
* <p>The time complexity is O(n * log(m)), n is the number of all elements in the
* sortedLists, m is the number of sortedLists.</p>
*/
private List<String> mergeSortedLists(List<List<String>> sortedLists) {
List<String> result = new ArrayList<>();
// Use a priority queue to store the current element of each list and its index in
// the list
PriorityQueue<Pair> minHeap = new PriorityQueue<>(
Comparator.comparing(pair -> pair.value));
// Initialize the priority queue and add the first element of each list to the queue
for (int i = 0; i < sortedLists.size(); i++) {
if (!sortedLists.get(i).isEmpty()) {
minHeap.add(new Pair(i, 0, sortedLists.get(i).get(0)));
}
}
while (!minHeap.isEmpty()) {
Pair current = minHeap.poll();
result.add(current.value());
// Add the next element of this list to the priority queue
if (current.indexInList() + 1 < sortedLists.get(current.listIndex()).size()) {
var list = sortedLists.get(current.listIndex());
minHeap.add(new Pair(current.listIndex(),
current.indexInList() + 1,
list.get(current.indexInList() + 1))
);
}
}
return result;
}
/**
* <p>A pair of element and its position in the original list.</p>
* <pre>
* listIndex: column index.
* indexInList: element index in the list.
* value: element value.
* </pre>
*/
private record Pair(int listIndex, int indexInList, String value) {
}
return fieldSelector != null
&& fieldSelector.query() != null
&& !(fieldSelector.query() instanceof All);
}
}

View File

@ -1,10 +1,7 @@
package run.halo.app.extension.index;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
@ -108,66 +105,4 @@ class IndexEntryImplTest {
Map.entry("slug-4", "fake-name-3"));
assertThat(entry2.indexedKeys()).containsSequence("slug-1", "slug-2", "slug-3", "slug-4");
}
@Test
void keyComparator() {
var comparator = IndexEntryImpl.KeyComparator.INSTANCE;
String[] strings = {"103", "101", "102", "1011", "1013", "1021", "1022", "1012", "1023"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(
new String[] {"101", "102", "103", "1011", "1012", "1013", "1021", "1022", "1023"});
Arrays.sort(strings, comparator.reversed());
assertThat(strings).isEqualTo(
new String[] {"1023", "1022", "1021", "1013", "1012", "1011", "103", "102", "101"});
// but if we use natural order, the result is:
Arrays.sort(strings, Comparator.naturalOrder());
assertThat(strings).isEqualTo(
new String[] {"101", "1011", "1012", "1013", "102", "1021", "1022", "1023", "103"});
}
@Test
void keyComparator2() {
var comparator = IndexEntryImpl.KeyComparator.INSTANCE;
String[] strings =
{"moment-101", "moment-102", "moment-103", "moment-1011", "moment-1013", "moment-1021",
"moment-1022", "moment-1012", "moment-1023"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"moment-101", "moment-102", "moment-103",
"moment-1011", "moment-1012", "moment-1013", "moment-1021", "moment-1022",
"moment-1023"});
// date sort
strings =
new String[] {"2022-01-15", "2022-02-01", "2021-12-25", "2022-01-01", "2022-01-02"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(
new String[] {"2021-12-25", "2022-01-01", "2022-01-02", "2022-01-15", "2022-02-01"});
// alphabet and number sort
strings = new String[] {"abc123", "abc45", "abc9", "abc100", "abc20"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(
new String[] {"abc9", "abc20", "abc45", "abc100", "abc123"});
// test for pure alphabet sort
strings = new String[] {"xyz", "abc", "def", "abcde", "xyzabc"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"abc", "abcde", "def", "xyz", "xyzabc"});
// test for empty string
strings = new String[] {"", "abc", "123", "xyz"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"", "123", "abc", "xyz"});
// test for the same string
strings = new String[] {"abc", "abc", "abc", "abc"};
Arrays.sort(strings, comparator);
assertThat(strings).isEqualTo(new String[] {"abc", "abc", "abc", "abc"});
// test for null element
assertThatThrownBy(() -> Arrays.sort(new String[] {null, "abc", "123", "xyz"}, comparator))
.isInstanceOf(NullPointerException.class);
}
}

View File

@ -5,7 +5,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
@ -13,10 +12,8 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -249,237 +246,4 @@ class IndexedQueryEngineImplTest {
static class DemoExtension extends AbstractExtension {
}
@Nested
@ExtendWith(MockitoExtension.class)
class ResultSorterTest {
@Test
void testSortByUnsorted() {
var fieldPathEntryMap = Map.<String, IndexEntry>of();
List<String> list = new ArrayList<>();
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
Sort sort = Sort.unsorted();
list.add("Item1");
list.add("Item2");
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).containsExactly("Item1", "Item2");
}
@Test
void testSortBySortedAscending() {
final var fieldPathEntryMap = new HashMap<String, IndexEntry>();
var entry = mock(IndexEntry.class);
when(entry.entries()).thenReturn(List.of(
Map.entry("key2", "Item2"),
Map.entry("key1", "Item1")
));
lenient().when(entry.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field1")
.setOrder(IndexSpec.OrderType.DESC)));
fieldPathEntryMap.put("field1", entry);
var list = new ArrayList<>(Arrays.asList("Item1", "Item2"));
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
var sort = Sort.by(Sort.Order.asc("field1"));
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).containsExactly("Item1", "Item2");
}
@Test
void testSortBySortedDescending() {
final var fieldPathEntryMap = new HashMap<String, IndexEntry>();
var entry = mock(IndexEntry.class);
when(entry.entries()).thenReturn(List.of(
Map.entry("key1", "Item1"),
Map.entry("key2", "Item2")
));
var indexDescriptor = new IndexDescriptor(new IndexSpec()
.setName("field1")
.setOrder(IndexSpec.OrderType.ASC));
when(entry.getIndexDescriptor()).thenReturn(indexDescriptor);
fieldPathEntryMap.put("field1", entry);
var list = new ArrayList<String>();
list.add("Item1");
list.add("Item2");
Sort sort = Sort.by(Sort.Order.desc("field1"));
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).containsSequence("Item2", "Item1");
}
@Test
void testSortByMultipleFields() {
final var fieldPathEntryMap = new LinkedHashMap<String, IndexEntry>();
var entry1 = mock(IndexEntry.class);
when(entry1.entries()).thenReturn(List.of(
Map.entry("k3", "Item3"),
Map.entry("k2", "Item2")
));
when(entry1.getIndexDescriptor()).thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field1")
.setOrder(IndexSpec.OrderType.DESC)));
var entry2 = mock(IndexEntry.class);
lenient().when(entry2.entries()).thenReturn(List.of(
Map.entry("k1", "Item1"),
Map.entry("k3", "Item3")
));
lenient().when(entry2.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field2")
.setOrder(IndexSpec.OrderType.ASC)));
fieldPathEntryMap.put("field1", entry1);
fieldPathEntryMap.put("field2", entry2);
final Sort sort = Sort.by(Sort.Order.asc("field1"),
Sort.Order.desc("field2"));
var list = new ArrayList<>(Arrays.asList("Item1", "Item2", "Item3"));
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).containsSequence("Item2", "Item3", "Item1");
}
@Test
void testSortByMultipleFields2() {
final var fieldPathEntryMap = new LinkedHashMap<String, IndexEntry>();
var entry1 = mock(IndexEntry.class);
when(entry1.entries()).thenReturn(List.of(
Map.entry("John", "John"),
Map.entry("Bob", "Bob"),
Map.entry("Alice", "Alice")
));
lenient().when(entry1.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field1")
.setOrder(IndexSpec.OrderType.DESC)));
var entry2 = mock(IndexEntry.class);
when(entry2.entries()).thenReturn(List.of(
Map.entry("David", "David"),
Map.entry("Eva", "Eva"),
Map.entry("Frank", "Frank")
));
lenient().when(entry2.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field2")
.setOrder(IndexSpec.OrderType.ASC)));
var entry3 = mock(IndexEntry.class);
lenient().when(entry3.entries()).thenReturn(List.of(
Map.entry("George", "George"),
Map.entry("Helen", "Helen"),
Map.entry("Ivy", "Ivy")
));
lenient().when(entry3.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field3")
.setOrder(IndexSpec.OrderType.ASC)));
fieldPathEntryMap.put("field1", entry1);
fieldPathEntryMap.put("field2", entry2);
fieldPathEntryMap.put("field3", entry3);
var list = new ArrayList<>(
Arrays.asList("Alice", "Bob", "Ivy", "Eva", "George"));
final Sort sort = Sort.by(Sort.Order.desc("field1"),
Sort.Order.asc("field2"),
Sort.Order.asc("field3"));
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).containsSequence("Bob", "Alice", "Eva", "George", "Ivy");
}
@Test
void testSortByWithMissingFieldInMap() {
var fieldPathEntryMap = new LinkedHashMap<String, IndexEntry>();
var list = new ArrayList<>(Arrays.asList("Item1", "Item2"));
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
Sort sort = Sort.by(Sort.Order.asc("missingField"));
assertThatThrownBy(() -> sorter.sortBy(sort))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(
"No index found for fieldPath: missingField, make sure you have created an "
+ "index for this field.");
}
@Test
void testSortByWithEmptyMap() {
var fieldPathEntryMap = new LinkedHashMap<String, IndexEntry>();
var entry = mock(IndexEntry.class);
when(entry.entries()).thenReturn(List.of());
lenient().when(entry.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field")
.setOrder(IndexSpec.OrderType.DESC)));
fieldPathEntryMap.put("field", entry);
var list = new ArrayList<>(Arrays.asList("Item1", "Item2"));
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
Sort sort = Sort.by(Sort.Order.asc("field"));
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).isEmpty();
}
@Test
void testSortByWithEmptyList() {
var fieldPathEntryMap = new LinkedHashMap<String, IndexEntry>();
var entry = mock(IndexEntry.class);
when(entry.entries()).thenReturn(List.of(
Map.entry("John", "John"),
Map.entry("Bob", "Bob")
));
lenient().when(entry.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field")
.setOrder(IndexSpec.OrderType.DESC)));
fieldPathEntryMap.put("field", entry);
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, List.of());
Sort sort = Sort.by(Sort.Order.asc("field"));
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).isEmpty();
}
@Test
void testSortByWithItemNotInIndex() {
var fieldPathEntryMap = new LinkedHashMap<String, IndexEntry>();
var entry = mock(IndexEntry.class);
when(entry.entries()).thenReturn(List.of(
Map.entry("Item2", "Item2"),
Map.entry("Item1", "Item1")
));
lenient().when(entry.getIndexDescriptor())
.thenReturn(new IndexDescriptor(new IndexSpec()
.setName("field")
.setOrder(IndexSpec.OrderType.DESC)));
fieldPathEntryMap.put("field", entry);
// Item3 is not in the index
var list = new ArrayList<>(Arrays.asList("Item1", "Item3"));
var sorter = new IndexedQueryEngineImpl.ResultSorter(fieldPathEntryMap, list);
Sort sort = Sort.by(Sort.Order.asc("field"));
List<String> sortedList = sorter.sortBy(sort);
assertThat(sortedList).containsExactly("Item1");
}
}
}
}