Add search engine and quick search for repositories (#1727)

Add a powerful search engine based on lucene to the scm-manager api.
The api can be used to index objects, simply by annotating them and add them to an index.
The first indexed object is the repository which could queried by quick search in the header.
This commit is contained in:
Sebastian Sdorra
2021-07-14 11:49:38 +02:00
committed by GitHub
parent ce4b869a7a
commit e321133ff7
88 changed files with 6052 additions and 25 deletions

View File

@@ -195,5 +195,6 @@ class IndexDtoGeneratorTest {
when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(scmPathInfo));
when(resourceLinks.me()).thenReturn(new ResourceLinks.MeLinks(scmPathInfo, new ResourceLinks.UserLinks(scmPathInfo)));
when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(scmPathInfo));
when(resourceLinks.search()).thenReturn(new ResourceLinks.SearchLinks(scmPathInfo));
}
}

View File

@@ -82,6 +82,7 @@ public class ResourceLinksMock {
lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo));
lenient().when(resourceLinks.apiKeyCollection()).thenReturn(new ResourceLinks.ApiKeyCollectionLinks(pathInfo));
lenient().when(resourceLinks.apiKey()).thenReturn(new ResourceLinks.ApiKeyLinks(pathInfo));
lenient().when(resourceLinks.search()).thenReturn(new ResourceLinks.SearchLinks(pathInfo));
return resourceLinks;
}

View File

@@ -0,0 +1,266 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.JsonNode;
import de.otto.edison.hal.HalRepresentation;
import lombok.Getter;
import lombok.Setter;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mapstruct.factory.Mappers;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.search.Hit;
import sonia.scm.search.IndexNames;
import sonia.scm.search.QueryResult;
import sonia.scm.search.SearchEngine;
import sonia.scm.web.JsonMockHttpResponse;
import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType;
import javax.annotation.Nonnull;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SearchResourceTest {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private SearchEngine searchEngine;
private RestDispatcher dispatcher;
@Mock
private HalEnricherRegistry enricherRegistry;
@BeforeEach
void setUpDispatcher() {
QueryResultMapper mapper = Mappers.getMapper(QueryResultMapper.class);
mapper.setRegistry(enricherRegistry);
SearchResource resource = new SearchResource(
searchEngine, mapper
);
dispatcher = new RestDispatcher();
dispatcher.addSingletonResource(resource);
}
@Test
void shouldEnrichQueryResult() throws IOException, URISyntaxException {
when(enricherRegistry.allByType(QueryResult.class))
.thenReturn(Collections.singleton(new SampleEnricher()));
mockQueryResult("Hello", result(0L));
JsonMockHttpResponse response = search("Hello");
JsonNode sample = response.getContentAsJson().get("_embedded").get("sample");
assertThat(sample.get("type").asText()).isEqualTo("java.lang.String");
}
@Test
void shouldEnrichHitResult() throws IOException, URISyntaxException {
when(enricherRegistry.allByType(QueryResult.class))
.thenReturn(Collections.emptySet());
when(enricherRegistry.allByType(Hit.class))
.thenReturn(Collections.singleton(new SampleEnricher()));
mockQueryResult("Hello", result(1L, "Hello"));
JsonMockHttpResponse response = search("Hello");
JsonNode sample = response.getContentAsJson()
.get("_embedded").get("hits").get(0)
.get("_embedded").get("sample");
assertThat(sample.get("type").asText()).isEqualTo("java.lang.String");
}
@Nested
class WithoutEnricher {
@BeforeEach
void setUpEnricherRegistry() {
when(enricherRegistry.allByType(QueryResult.class)).thenReturn(Collections.emptySet());
lenient().when(enricherRegistry.allByType(Hit.class)).thenReturn(Collections.emptySet());
}
@Test
void shouldReturnVndContentType() throws UnsupportedEncodingException, URISyntaxException {
mockQueryResult("Hello", result(0L));
JsonMockHttpResponse response = search("Hello");
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
assertHeader(response, "Content-Type", VndMediaType.QUERY_RESULT);
}
@Test
void shouldReturnPagingLinks() throws IOException, URISyntaxException {
mockQueryResult(20, 20, "paging", result(100));
JsonMockHttpResponse response = search("paging", 1, 20);
JsonNode links = response.getContentAsJson().get("_links");
assertLink(links, "self", "/v2/search?q=paging&page=1&pageSize=20");
assertLink(links, "first", "/v2/search?q=paging&page=0&pageSize=20");
assertLink(links, "prev", "/v2/search?q=paging&page=0&pageSize=20");
assertLink(links, "next", "/v2/search?q=paging&page=2&pageSize=20");
assertLink(links, "last", "/v2/search?q=paging&page=4&pageSize=20");
}
@Test
void shouldPagingFields() throws IOException, URISyntaxException {
mockQueryResult(20, 20, "pagingFields", result(100));
JsonMockHttpResponse response = search("pagingFields", 1, 20);
JsonNode root = response.getContentAsJson();
assertThat(root.get("page").asInt()).isOne();
assertThat(root.get("pageTotal").asInt()).isEqualTo(5);
}
@Test
void shouldReturnType() throws IOException, URISyntaxException {
mockQueryResult("Hello", result(0L));
JsonMockHttpResponse response = search("Hello");
JsonNode root = response.getContentAsJson();
assertThat(root.get("type").asText()).isEqualTo("java.lang.String");
}
@Test
void shouldReturnHitsAsEmbedded() throws IOException, URISyntaxException {
mockQueryResult("Hello", result(2L, "Hello", "Hello Again"));
JsonMockHttpResponse response = search("Hello");
JsonNode hits = response.getContentAsJson().get("_embedded").get("hits");
assertThat(hits.size()).isEqualTo(2);
JsonNode first = hits.get(0);
assertThat(first.get("score").asDouble()).isEqualTo(2d);
JsonNode fields = first.get("fields");
JsonNode valueField = fields.get("value");
assertThat(valueField.get("highlighted").asBoolean()).isFalse();
assertThat(valueField.get("value").asText()).isEqualTo("Hello");
JsonNode highlightedField = fields.get("highlighted");
assertThat(highlightedField.get("highlighted").asBoolean()).isTrue();
assertThat(highlightedField.get("fragments").get(0).asText()).isEqualTo("Hello");
}
}
private void assertLink(JsonNode links, String self, String s) {
assertThat(links.get(self).get("href").asText()).isEqualTo(s);
}
private QueryResult result(long totalHits, Object... values) {
List<Hit> hits = new ArrayList<>();
for (int i = 0; i < values.length; i++) {
hits.add(hit(i, values));
}
return new QueryResult(totalHits, String.class, hits);
}
@Nonnull
private Hit hit(int i, Object[] values) {
Map<String, Hit.Field> fields = fields(values[i]);
return new Hit("" + i, values.length - i, fields);
}
@Nonnull
private Map<String, Hit.Field> fields(Object value) {
Map<String, Hit.Field> fields = new HashMap<>();
fields.put("value", new Hit.ValueField(value));
fields.put("highlighted", new Hit.HighlightedField(new String[]{value.toString()}));
return fields;
}
private void mockQueryResult(String query, QueryResult result) {
mockQueryResult(0, 10, query, result);
}
private void mockQueryResult(int start, int limit, String query, QueryResult result) {
when(
searchEngine.search(IndexNames.DEFAULT)
.start(start)
.limit(limit)
.execute(Repository.class, query)
).thenReturn(result);
}
private void assertHeader(JsonMockHttpResponse response, String header, String expectedValue) {
assertThat(response.getOutputHeaders().getFirst(header)).hasToString(expectedValue);
}
private JsonMockHttpResponse search(String query) throws URISyntaxException, UnsupportedEncodingException {
return search(query, null, null);
}
private JsonMockHttpResponse search(String query, Integer page, Integer pageSize) throws URISyntaxException, UnsupportedEncodingException {
String uri = "/v2/search?q=" + URLEncoder.encode(query, "UTF-8");
if (page != null) {
uri += "&page=" + page;
}
if (pageSize != null) {
uri += "&pageSize=" + pageSize;
}
MockHttpRequest request = MockHttpRequest.get(uri);
JsonMockHttpResponse response = new JsonMockHttpResponse();
dispatcher.invoke(request, response);
return response;
}
@Getter
@Setter
public static class SampleEmbedded extends HalRepresentation {
private Class<?> type;
}
private static class SampleEnricher implements HalEnricher {
@Override
public void enrich(HalEnricherContext context, HalAppender appender) {
QueryResult result = context.oneRequireByType(QueryResult.class);
SampleEmbedded embedded = new SampleEmbedded();
embedded.setType(result.getType());
appender.appendEmbedded("sample", embedded);
}
}
}

View File

@@ -0,0 +1,147 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository;
import com.google.common.collect.ImmutableList;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLog;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.IndexNames;
import sonia.scm.search.IndexQueue;
import sonia.scm.web.security.AdministrationContext;
import java.util.List;
import java.util.Optional;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexUpdateListenerTest {
@Mock
private RepositoryManager repositoryManager;
@Mock
private AdministrationContext administrationContext;
@Mock
private IndexQueue indexQueue;
@Mock
private Index index;
@Mock
private IndexLogStore indexLogStore;
@InjectMocks
private IndexUpdateListener updateListener;
@Test
@SuppressWarnings("java:S6068")
void shouldIndexAllRepositories() {
when(indexLogStore.get(IndexNames.DEFAULT, Repository.class)).thenReturn(Optional.empty());
doAnswer(ic -> {
IndexUpdateListener.ReIndexAll reIndexAll = new IndexUpdateListener.ReIndexAll(repositoryManager, indexQueue);
reIndexAll.run();
return null;
})
.when(administrationContext)
.runAsAdmin(IndexUpdateListener.ReIndexAll.class);
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
Repository puzzle42 = RepositoryTestData.create42Puzzle();
List<Repository> repositories = ImmutableList.of(heartOfGold, puzzle42);
when(repositoryManager.getAll()).thenReturn(repositories);
when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index);
updateListener.contextInitialized(null);
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
verify(index).store(Id.of(puzzle42), RepositoryPermissions.read(puzzle42).asShiroString(), puzzle42);
verify(index).close();
verify(indexLogStore).log(IndexNames.DEFAULT, Repository.class, IndexUpdateListener.INDEX_VERSION);
}
@Test
void shouldSkipReIndex() {
IndexLog log = new IndexLog(1);
when(indexLogStore.get(IndexNames.DEFAULT, Repository.class)).thenReturn(Optional.of(log));
updateListener.contextInitialized(null);
verifyNoInteractions(indexQueue);
}
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = "BEFORE_.*")
void shouldIgnoreBeforeEvents(HandlerEventType type) {
RepositoryEvent event = new RepositoryEvent(type, RepositoryTestData.create42Puzzle());
updateListener.handleEvent(event);
verifyNoInteractions(indexQueue);
}
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.INCLUDE, names = {"CREATE", "MODIFY"})
void shouldStore(HandlerEventType type) {
when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index);
Repository puzzle = RepositoryTestData.create42Puzzle();
RepositoryEvent event = new RepositoryEvent(type, puzzle);
updateListener.handleEvent(event);
verify(index).store(Id.of(puzzle), RepositoryPermissions.read(puzzle).asShiroString(), puzzle);
verify(index).close();
}
@Test
void shouldDelete() {
when(indexQueue.getQueuedIndex(IndexNames.DEFAULT)).thenReturn(index);
Repository puzzle = RepositoryTestData.create42Puzzle();
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, puzzle);
updateListener.handleEvent(event);
verify(index).deleteByRepository(puzzle.getId());
verify(index).close();
}
}

View File

@@ -0,0 +1,78 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.de.GermanAnalyzer;
import org.apache.lucene.analysis.en.EnglishAnalyzer;
import org.apache.lucene.analysis.es.SpanishAnalyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.junit.jupiter.api.Test;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
class AnalyzerFactoryTest {
private final AnalyzerFactory analyzerFactory = new AnalyzerFactory();
@Test
void shouldReturnStandardAnalyzer() {
Analyzer analyzer = analyzerFactory.create(IndexOptions.defaults());
assertThat(analyzer).isInstanceOf(StandardAnalyzer.class);
}
@Test
void shouldReturnStandardAnalyzerForUnknownLocale() {
Analyzer analyzer = analyzerFactory.create(IndexOptions.naturalLanguage(Locale.CHINESE));
assertThat(analyzer).isInstanceOf(StandardAnalyzer.class);
}
@Test
void shouldReturnEnglishAnalyzer() {
Analyzer analyzer = analyzerFactory.create(IndexOptions.naturalLanguage(Locale.ENGLISH));
assertThat(analyzer).isInstanceOf(EnglishAnalyzer.class);
}
@Test
void shouldReturnGermanAnalyzer() {
Analyzer analyzer = analyzerFactory.create(IndexOptions.naturalLanguage(Locale.GERMAN));
assertThat(analyzer).isInstanceOf(GermanAnalyzer.class);
}
@Test
void shouldReturnGermanAnalyzerForLocaleGermany() {
Analyzer analyzer = analyzerFactory.create(IndexOptions.naturalLanguage(Locale.GERMANY));
assertThat(analyzer).isInstanceOf(GermanAnalyzer.class);
}
@Test
void shouldReturnSpanishAnalyzer() {
Analyzer analyzer = analyzerFactory.create(IndexOptions.naturalLanguage(new Locale("es", "ES")));
assertThat(analyzer).isInstanceOf(SpanishAnalyzer.class);
}
}

View File

@@ -0,0 +1,61 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import sonia.scm.store.InMemoryByteDataStoreFactory;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class DefaultIndexLogStoreTest {
private IndexLogStore indexLogStore;
@BeforeEach
void setUpIndexLogStore() {
InMemoryByteDataStoreFactory dataStoreFactory = new InMemoryByteDataStoreFactory();
indexLogStore = new DefaultIndexLogStore(dataStoreFactory);
}
@Test
void shouldReturnEmptyOptional() {
Optional<IndexLog> indexLog = indexLogStore.get("index", String.class);
assertThat(indexLog).isEmpty();
}
@Test
void shouldStoreLog() {
indexLogStore.log("index", String.class, 42);
Optional<IndexLog> index = indexLogStore.get("index", String.class);
assertThat(index).hasValueSatisfying(log -> {
assertThat(log.getVersion()).isEqualTo(42);
assertThat(log.getDate()).isNotNull();
});
}
}

View File

@@ -0,0 +1,145 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import lombok.Value;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DefaultIndexQueueTest {
private Directory directory;
private DefaultIndexQueue queue;
@Mock
private LuceneQueryBuilderFactory queryBuilderFactory;
@BeforeEach
void createQueue() throws IOException {
directory = new ByteBuffersDirectory();
IndexOpener factory = mock(IndexOpener.class);
when(factory.openForWrite(any(String.class), any(IndexOptions.class))).thenAnswer(ic -> {
IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer());
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
return new IndexWriter(directory, config);
});
SearchEngine engine = new LuceneSearchEngine(factory, new DocumentConverter(), queryBuilderFactory);
queue = new DefaultIndexQueue(engine);
}
@AfterEach
void closeQueue() throws IOException {
queue.close();
directory.close();
}
@Test
void shouldWriteToIndex() throws Exception {
try (Index index = queue.getQueuedIndex("default")) {
index.store(Id.of("tricia"), null, new Account("tricia", "Trillian", "McMillan"));
index.store(Id.of("dent"), null, new Account("dent", "Arthur", "Dent"));
}
assertDocCount(2);
}
@Test
void shouldWriteMultiThreaded() throws Exception {
ExecutorService executorService = Executors.newFixedThreadPool(4);
for (int i = 0; i < 20; i++) {
executorService.execute(new IndexNumberTask(i));
}
executorService.execute(() -> {
try (Index index = queue.getQueuedIndex("default")) {
index.delete(Id.of(String.valueOf(12)), IndexedNumber.class);
}
});
executorService.shutdown();
assertDocCount(19);
}
private void assertDocCount(int expectedCount) throws IOException {
// wait until all tasks are finished
await().until(() -> queue.getSize() == 0);
try (DirectoryReader reader = DirectoryReader.open(directory)) {
assertThat(reader.numDocs()).isEqualTo(expectedCount);
}
}
@Value
public static class Account {
@Indexed
String username;
@Indexed
String firstName;
@Indexed
String lastName;
}
@Value
public static class IndexedNumber {
@Indexed
int value;
}
public class IndexNumberTask implements Runnable {
private final int number;
public IndexNumberTask(int number) {
this.number = number;
}
@Override
public void run() {
try (Index index = queue.getQueuedIndex("default")) {
index.store(Id.of(String.valueOf(number)), null, new IndexedNumber(number));
}
}
}
}

View File

@@ -0,0 +1,288 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexableField;
import org.apache.lucene.index.IndexableFieldType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class DocumentConverterTest {
private DocumentConverter documentConverter;
@BeforeEach
void prepare() {
documentConverter = new DocumentConverter();
}
@Test
void shouldConvertPersonToDocument() {
Person person = new Person("Arthur", "Dent");
Document document = documentConverter.convert(person);
assertThat(document.getField("firstName").stringValue()).isEqualTo("Arthur");
assertThat(document.getField("lastName").stringValue()).isEqualTo("Dent");
}
@Test
void shouldUseNameFromAnnotation() {
Document document = documentConverter.convert(new ParamSample());
assertThat(document.getField("username").stringValue()).isEqualTo("dent");
}
@Test
void shouldBeIndexedAsTextFieldByDefault() {
Document document = documentConverter.convert(new ParamSample());
assertThat(document.getField("username")).isInstanceOf(TextField.class);
}
@Test
void shouldBeIndexedAsStringField() {
Document document = documentConverter.convert(new ParamSample());
assertThat(document.getField("searchable")).isInstanceOf(StringField.class);
}
@Test
void shouldBeIndexedAsStoredField() {
Document document = documentConverter.convert(new ParamSample());
assertThat(document.getField("storedOnly")).isInstanceOf(StoredField.class);
}
@Test
void shouldIgnoreNonIndexedFields() {
Document document = documentConverter.convert(new ParamSample());
assertThat(document.getField("notIndexed")).isNull();
}
@Test
void shouldSupportInheritance() {
Account account = new Account("Arthur", "Dent", "arthur@hitchhiker.com");
Document document = documentConverter.convert(account);
assertThat(document.getField("firstName")).isNotNull();
assertThat(document.getField("lastName")).isNotNull();
assertThat(document.getField("mail")).isNotNull();
}
@Test
void shouldFailWithoutGetter() {
WithoutGetter withoutGetter = new WithoutGetter();
assertThrows(NonReadableFieldException.class, () -> documentConverter.convert(withoutGetter));
}
@Test
void shouldFailOnUnsupportedFieldType() {
UnsupportedFieldType unsupportedFieldType = new UnsupportedFieldType();
assertThrows(UnsupportedTypeOfFieldException.class, () -> documentConverter.convert(unsupportedFieldType));
}
@Test
void shouldStoreLongFieldsAsPointAndStoredByDefault() {
Document document = documentConverter.convert(new SupportedTypes());
assertPointField(document, "longType",
field -> assertThat(field.numericValue().longValue()).isEqualTo(42L)
);
}
@Test
void shouldStoreLongFieldAsStored() {
Document document = documentConverter.convert(new SupportedTypes());
IndexableField field = document.getField("storedOnlyLongType");
assertThat(field).isInstanceOf(StoredField.class);
assertThat(field.numericValue().longValue()).isEqualTo(42L);
}
@Test
void shouldStoreIntegerFieldsAsPointAndStoredByDefault() {
Document document = documentConverter.convert(new SupportedTypes());
assertPointField(document, "intType",
field -> assertThat(field.numericValue().intValue()).isEqualTo(42)
);
}
@Test
void shouldStoreIntegerFieldAsStored() {
Document document = documentConverter.convert(new SupportedTypes());
IndexableField field = document.getField("storedOnlyIntegerType");
assertThat(field).isInstanceOf(StoredField.class);
assertThat(field.numericValue().intValue()).isEqualTo(42);
}
@Test
void shouldStoreBooleanFieldsAsStringField() {
Document document = documentConverter.convert(new SupportedTypes());
IndexableField field = document.getField("boolType");
assertThat(field).isInstanceOf(StringField.class);
assertThat(field.stringValue()).isEqualTo("true");
assertThat(field.fieldType().stored()).isTrue();
}
@Test
void shouldStoreBooleanFieldAsStored() {
Document document = documentConverter.convert(new SupportedTypes());
IndexableField field = document.getField("storedOnlyBoolType");
assertThat(field).isInstanceOf(StoredField.class);
assertThat(field.stringValue()).isEqualTo("true");
}
@Test
void shouldStoreInstantFieldsAsPointAndStoredByDefault() {
Instant now = Instant.now();
Document document = documentConverter.convert(new DateTypes(now));
assertPointField(document, "instant",
field -> assertThat(field.numericValue().longValue()).isEqualTo(now.toEpochMilli())
);
}
@Test
void shouldStoreInstantFieldAsStored() {
Instant now = Instant.now();
Document document = documentConverter.convert(new DateTypes(now));
IndexableField field = document.getField("storedOnlyInstant");
assertThat(field).isInstanceOf(StoredField.class);
assertThat(field.numericValue().longValue()).isEqualTo(now.toEpochMilli());
}
@Test
void shouldCreateNoFieldForNullValues() {
Document document = documentConverter.convert(new Person("Trillian", null));
assertThat(document.getField("firstName")).isNotNull();
assertThat(document.getField("lastName")).isNull();
}
private void assertPointField(Document document, String name, Consumer<IndexableField> consumer) {
IndexableField[] fields = document.getFields(name);
assertThat(fields)
.allSatisfy(consumer)
.anySatisfy(field -> assertThat(field.fieldType().stored()).isFalse())
.anySatisfy(field -> assertThat(field.fieldType().stored()).isTrue());
}
@Getter
@AllArgsConstructor
public static class Person {
@Indexed
private String firstName;
@Indexed
private String lastName;
}
@Getter
public static class Account extends Person {
@Indexed
private String mail;
public Account(String firstName, String lastName, String mail) {
super(firstName, lastName);
this.mail = mail;
}
}
@Getter
public static class ParamSample {
@Indexed(name = "username")
private final String name = "dent";
@Indexed(type = Indexed.Type.SEARCHABLE)
private final String searchable = "--";
@Indexed(type = Indexed.Type.STORED_ONLY)
private final String storedOnly = "--";
private final String notIndexed = "--";
}
public static class WithoutGetter {
@Indexed
private final String value = "one";
}
@Getter
public static class UnsupportedFieldType {
@Indexed
private final Object value = "one";
}
@Getter
public static class SupportedTypes {
@Indexed
private final Long longType = 42L;
@Indexed(type = Indexed.Type.STORED_ONLY)
private final long storedOnlyLongType = 42L;
@Indexed
private final int intType = 42;
@Indexed(type = Indexed.Type.STORED_ONLY)
private final Integer storedOnlyIntegerType = 42;
@Indexed
private final boolean boolType = true;
@Indexed(type = Indexed.Type.STORED_ONLY)
private final boolean storedOnlyBoolType = true;
}
@Getter
private static class DateTypes {
@Indexed
private final Instant instant;
@Indexed(type = Indexed.Type.STORED_ONLY)
private final Instant storedOnlyInstant;
private DateTypes(Instant instant) {
this.instant = instant;
this.storedOnlyInstant = instant;
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.apache.lucene.analysis.core.SimpleAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexOpenerTest {
private Path directory;
@Mock
private AnalyzerFactory analyzerFactory;
private IndexOpener indexOpener;
@BeforeEach
void createIndexWriterFactory(@TempDir Path tempDirectory) {
this.directory = tempDirectory;
SCMContextProvider context = mock(SCMContextProvider.class);
when(context.resolve(Paths.get("index"))).thenReturn(tempDirectory);
when(analyzerFactory.create(any(IndexOptions.class))).thenReturn(new SimpleAnalyzer());
indexOpener = new IndexOpener(context, analyzerFactory);
}
@Test
void shouldCreateNewIndex() throws IOException {
try (IndexWriter writer = indexOpener.openForWrite("new-index", IndexOptions.defaults())) {
addDoc(writer, "Trillian");
}
assertThat(directory.resolve("new-index")).exists();
}
@Test
void shouldOpenExistingIndex() throws IOException {
try (IndexWriter writer = indexOpener.openForWrite("reused", IndexOptions.defaults())) {
addDoc(writer, "Dent");
}
try (IndexWriter writer = indexOpener.openForWrite("reused", IndexOptions.defaults())) {
assertThat(writer.getFieldNames()).contains("hitchhiker");
}
}
@Test
void shouldUseAnalyzerFromFactory() throws IOException {
try (IndexWriter writer = indexOpener.openForWrite("new-index", IndexOptions.defaults())) {
assertThat(writer.getAnalyzer()).isInstanceOf(SimpleAnalyzer.class);
}
}
private void addDoc(IndexWriter writer, String name) throws IOException {
Document doc = new Document();
doc.add(new TextField("hitchhiker", name, Field.Store.YES));
writer.addDocument(doc);
}
}

View File

@@ -0,0 +1,238 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import lombok.Value;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.search.FieldNames.*;
class LuceneIndexTest {
private static final Id ONE = Id.of("one");
private Directory directory;
@BeforeEach
void createDirectory() {
directory = new ByteBuffersDirectory();
}
@Test
void shouldStoreObject() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Awesome content which should be indexed"));
}
assertHits("value", "content", 1);
}
@Test
void shouldUpdateObject() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Awesome content which should be indexed"));
index.store(ONE, null, new Storable("Awesome content"));
}
assertHits("value", "content", 1);
}
@Test
void shouldStoreUidOfObject() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Awesome content which should be indexed"));
}
assertHits(UID, "one/" + Storable.class.getName(), 1);
}
@Test
void shouldStoreIdOfObject() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Some text"));
}
assertHits(ID, "one", 1);
}
@Test
void shouldStoreRepositoryOfId() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE.withRepository("4211"), null, new Storable("Some text"));
}
assertHits(REPOSITORY, "4211", 1);
}
@Test
void shouldStoreTypeOfObject() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Some other text"));
}
assertHits(TYPE, Storable.class.getName(), 1);
}
@Test
void shouldDeleteById() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Some other text"));
}
try (LuceneIndex index = createIndex()) {
index.delete(ONE, Storable.class);
}
assertHits(ID, "one", 0);
}
@Test
void shouldDeleteAllByType() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("content"));
index.store(Id.of("two"), null, new Storable("content"));
index.store(Id.of("three"), null, new OtherStorable("content"));
}
try (LuceneIndex index = createIndex()) {
index.deleteByType(Storable.class);
}
assertHits("value", "content", 1);
}
@Test
void shouldDeleteByIdAnyType() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Some text"));
index.store(ONE, null, new OtherStorable("Some other text"));
}
try (LuceneIndex index = createIndex()) {
index.delete(ONE, Storable.class);
}
assertHits(ID, "one", 1);
ScoreDoc[] docs = assertHits(ID, "one", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get("value")).isEqualTo("Some other text");
}
@Test
void shouldDeleteByIdAndRepository() throws IOException {
Id withRepository = ONE.withRepository("4211");
try (LuceneIndex index = createIndex()) {
index.store(ONE, null, new Storable("Some other text"));
index.store(withRepository, null, new Storable("New stuff"));
}
try (LuceneIndex index = createIndex()) {
index.delete(withRepository, Storable.class);
}
ScoreDoc[] docs = assertHits(ID, "one", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get("value")).isEqualTo("Some other text");
}
@Test
void shouldDeleteByRepository() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE.withRepository("4211"), null, new Storable("Some other text"));
index.store(ONE.withRepository("4212"), null, new Storable("New stuff"));
}
try (LuceneIndex index = createIndex()) {
index.deleteByRepository("4212");
}
assertHits(ID, "one", 1);
}
@Test
void shouldStorePermission() throws IOException {
try (LuceneIndex index = createIndex()) {
index.store(ONE.withRepository("4211"), "repo:4211:read", new Storable("Some other text"));
}
assertHits(PERMISSION, "repo:4211:read", 1);
}
private Document doc(int doc) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
return reader.document(doc);
}
}
@CanIgnoreReturnValue
private ScoreDoc[] assertHits(String field, String value, int expectedHits) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
IndexSearcher searcher = new IndexSearcher(reader);
TopDocs docs = searcher.search(new TermQuery(new Term(field, value)), 10);
assertThat(docs.totalHits.value).isEqualTo(expectedHits);
return docs.scoreDocs;
}
}
private LuceneIndex createIndex() throws IOException {
return new LuceneIndex(new DocumentConverter(), createWriter());
}
private IndexWriter createWriter() throws IOException {
IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer());
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
return new IndexWriter(directory, config);
}
@Value
private static class Storable {
@Indexed
String value;
}
@Value
private static class OtherStorable {
@Indexed
String value;
}
}

View File

@@ -0,0 +1,592 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.IntPoint;
import org.apache.lucene.document.LongPoint;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@SubjectAware(value = "trillian", permissions = "abc")
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class LuceneQueryBuilderTest {
private Directory directory;
@Mock
private IndexOpener opener;
@BeforeEach
void setUpDirectory() {
directory = new ByteBuffersDirectory();
}
@Test
void shouldReturnHitsForBestGuessQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211"));
}
QueryResult result = query(InetOrgPerson.class, "Arthur");
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldMatchPartial() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(personDoc("Trillian"));
}
QueryResult result = query(Person.class, "Trill");
assertThat(result.getTotalHits()).isOne();
}
@Test
@SuppressWarnings("java:S5976")
void shouldNotAppendWildcardIfStarIsUsed() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(simpleDoc("Trillian"));
}
QueryResult result = query(Simple.class, "Tr*ll");
assertThat(result.getTotalHits()).isZero();
}
@Test
@SuppressWarnings("java:S5976")
void shouldNotAppendWildcardIfQuestionMarkIsUsed() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(simpleDoc("Trillian"));
}
QueryResult result = query(Simple.class, "Tr?ll");
assertThat(result.getTotalHits()).isZero();
}
@Test
@SuppressWarnings("java:S5976")
void shouldNotAppendWildcardIfExpertQueryIsUsed() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(simpleDoc("Trillian"));
}
QueryResult result = query(Simple.class, "lastName:Trill");
assertThat(result.getTotalHits()).isZero();
}
@Test
void shouldSupportFieldsFromParentClass() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211"));
}
QueryResult result = query(InetOrgPerson.class, "Dent");
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldIgnoreHitsOfOtherType() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211"));
writer.addDocument(personDoc("Dent"));
}
QueryResult result = query(InetOrgPerson.class, "Dent");
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldThrowQueryParseExceptionOnInvalidQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(personDoc("Dent"));
}
assertThrows(QueryParseException.class, () -> query(String.class, ":~:~"));
}
@Test
void shouldIgnoreNonDefaultFieldsForBestGuessQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "car"));
}
QueryResult result = query(InetOrgPerson.class, "car");
assertThat(result.getTotalHits()).isZero();
}
@Test
void shouldUseBoostFromAnnotationForBestGuessQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arti", "car"));
writer.addDocument(inetOrgPersonDoc("Fake", "Dent", "Arthur, Arthur, Arthur", "mycar"));
}
QueryResult result = query(InetOrgPerson.class, "Arthur");
assertThat(result.getTotalHits()).isEqualTo(2);
List<Hit> hits = result.getHits();
Hit arthur = hits.get(0);
assertValueField(arthur, "firstName", "Arthur");
Hit fake = hits.get(1);
assertValueField(fake, "firstName", "Fake");
assertThat(arthur.getScore()).isGreaterThan(fake.getScore());
}
private void assertValueField(Hit hit, String fieldName, Object value) {
assertThat(hit.getFields().get(fieldName))
.isInstanceOfSatisfying(Hit.ValueField.class, (field) -> {
assertThat(field.isHighlighted()).isFalse();
assertThat(field.getValue()).isEqualTo(value);
});
}
@Test
void shouldReturnHitsForExpertQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(simpleDoc("Awesome content one"));
writer.addDocument(simpleDoc("Awesome content two"));
writer.addDocument(simpleDoc("Awesome content three"));
}
QueryResult result = query(Simple.class, "content:awesome");
assertThat(result.getTotalHits()).isEqualTo(3L);
assertThat(result.getHits()).hasSize(3);
}
@Test
void shouldReturnOnlyHitsOfTypeForExpertQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Ford", "Prefect", "Ford Prefect", "4211"));
writer.addDocument(personDoc("Prefect"));
}
QueryResult result = query(InetOrgPerson.class, "lastName:prefect");
assertThat(result.getTotalHits()).isEqualTo(1L);
}
@Test
void shouldReturnOnlyPermittedHits() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(permissionDoc("Awesome content one", "abc"));
writer.addDocument(permissionDoc("Awesome content two", "cde"));
writer.addDocument(permissionDoc("Awesome content three", "fgh"));
}
QueryResult result = query(Simple.class, "content:awesome");
assertThat(result.getTotalHits()).isOne();
List<Hit> hits = result.getHits();
assertThat(hits).hasSize(1).allSatisfy(hit -> {
assertValueField(hit, "content", "Awesome content one");
assertThat(hit.getScore()).isGreaterThan(0f);
});
}
@Test
void shouldFilterByRepository() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(repositoryDoc("Awesome content one", "abc"));
writer.addDocument(repositoryDoc("Awesome content two", "cde"));
writer.addDocument(repositoryDoc("Awesome content three", "fgh"));
}
QueryResult result;
try (DirectoryReader reader = DirectoryReader.open(directory)) {
when(opener.openForRead("default")).thenReturn(reader);
LuceneQueryBuilder builder = new LuceneQueryBuilder(
opener, "default", new StandardAnalyzer()
);
result = builder.repository("cde").execute(Simple.class, "content:awesome");
}
assertThat(result.getTotalHits()).isOne();
List<Hit> hits = result.getHits();
assertThat(hits).hasSize(1).allSatisfy(hit -> {
assertValueField(hit, "content", "Awesome content two");
assertThat(hit.getScore()).isGreaterThan(0f);
});
}
@Test
void shouldReturnStringFields() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(simpleDoc("Awesome"));
}
QueryResult result = query(Simple.class, "content:awesome");
assertThat(result.getTotalHits()).isOne();
assertThat(result.getHits()).allSatisfy(
hit -> assertValueField(hit, "content", "Awesome")
);
}
@Test
void shouldReturnIdOfHit() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Slarti", "Bartfass", "Slartibartfass", "-"));
}
QueryResult result = query(InetOrgPerson.class, "lastName:Bartfass");
assertThat(result.getTotalHits()).isOne();
assertThat(result.getHits()).allSatisfy(
hit -> assertThat(hit.getId()).isEqualTo("Bartfass")
);
}
@Test
void shouldReturnTypeOfHits() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(simpleDoc("We need the type"));
}
QueryResult result = query(Simple.class, "content:type");
assertThat(result.getType()).isEqualTo(Simple.class);
}
@Test
void shouldSupportIntRangeQueries() throws IOException {
Instant now = Instant.now();
try (IndexWriter writer = writer()) {
writer.addDocument(typesDoc(42, 21L, false, now));
}
QueryResult result = query(Types.class, "intValue:[0 TO 100]");
assertThat(result.getTotalHits()).isOne();
assertThat(result.getHits()).allSatisfy(
hit -> assertValueField(hit, "intValue", 42)
);
}
@Test
void shouldSupportLongRangeQueries() throws IOException {
Instant now = Instant.now();
try (IndexWriter writer = writer()) {
writer.addDocument(typesDoc(42, 21L, false, now));
}
QueryResult result = query(Types.class, "longValue:[0 TO 100]");
assertThat(result.getTotalHits()).isOne();
assertThat(result.getHits()).allSatisfy(
hit -> assertValueField(hit, "longValue", 21L)
);
}
@Test
void shouldSupportInstantRangeQueries() throws IOException {
Instant now = Instant.ofEpochMilli(Instant.now().toEpochMilli());
try (IndexWriter writer = writer()) {
writer.addDocument(typesDoc(42, 21L, false, now));
}
long before = now.minus(1, ChronoUnit.MINUTES).toEpochMilli();
long after = now.plus(1, ChronoUnit.MINUTES).toEpochMilli();
String queryString = String.format("instantValue:[%d TO %d]", before, after);
QueryResult result = query(Types.class, queryString);
assertThat(result.getTotalHits()).isOne();
assertThat(result.getHits()).allSatisfy(
hit -> assertValueField(hit, "instantValue", now)
);
}
@Test
void shouldSupportQueryForBooleanFields() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(typesDoc(21, 42L, true, Instant.now()));
}
QueryResult result = query(Types.class, "boolValue:true");
assertThat(result.getTotalHits()).isOne();
assertThat(result.getHits()).allSatisfy(
hit -> assertValueField(hit, "boolValue", Boolean.TRUE)
);
}
@Test
void shouldReturnValueFieldForHighlightedFieldWithoutFragment() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Marvin", "HoG", "Paranoid Android", "4211"));
}
QueryResult result = query(InetOrgPerson.class, "Marvin");
Hit hit = result.getHits().get(0);
assertValueField(hit, "displayName", "Paranoid Android");
}
@Test
void shouldFailBestGuessQueryWithoutDefaultQueryFields() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(typesDoc(1, 2L, false, Instant.now()));
}
assertThrows(NoDefaultQueryFieldsFoundException.class, () -> query(Types.class, "something"));
}
@Test
void shouldLimitHitsByDefaultSize() throws IOException {
try (IndexWriter writer = writer()) {
for (int i = 0; i < 20; i++)
writer.addDocument(simpleDoc("counter " + i));
}
QueryResult result = query(Simple.class, "content:counter");
assertThat(result.getTotalHits()).isEqualTo(20L);
assertThat(result.getHits()).hasSize(10);
}
@Test
void shouldLimitHitsByConfiguredSize() throws IOException {
try (IndexWriter writer = writer()) {
for (int i = 0; i < 20; i++)
writer.addDocument(simpleDoc("counter " + (i + 1)));
}
QueryResult result = query(Simple.class, "content:counter", null, 2);
assertThat(result.getTotalHits()).isEqualTo(20L);
assertThat(result.getHits()).hasSize(2);
assertContainsValues(
result, "content", "counter 1", "counter 2"
);
}
@Test
void shouldRespectStartValue() throws IOException {
try (IndexWriter writer = writer()) {
for (int i = 0; i < 20; i++)
writer.addDocument(simpleDoc("counter " + (i + 1)));
}
QueryResult result = query(Simple.class, "content:counter", 10, 3);
assertThat(result.getTotalHits()).isEqualTo(20L);
assertThat(result.getHits()).hasSize(3);
assertContainsValues(
result, "content", "counter 11", "counter 12", "counter 13"
);
}
private void assertContainsValues(QueryResult result, String fieldName, Object... expectedValues) {
List<Object> values = result.getHits().stream().map(hit -> {
Hit.ValueField content = (Hit.ValueField) hit.getFields().get(fieldName);
return content.getValue();
}).collect(Collectors.toList());
assertThat(values).containsExactly(expectedValues);
}
@Test
void shouldBeAbleToMarshalQueryResultToJson() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211"));
}
QueryResult result = query(InetOrgPerson.class, "Arthur");
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.valueToTree(result);
assertThat(root.get("totalHits").asInt()).isOne();
JsonNode hit = root.get("hits").get(0);
assertThat(hit.get("score").asDouble()).isGreaterThan(0d);
JsonNode fields = hit.get("fields");
JsonNode firstName = fields.get("firstName");
assertThat(firstName.get("highlighted").asBoolean()).isFalse();
assertThat(firstName.get("value").asText()).isEqualTo("Arthur");
JsonNode displayName = fields.get("displayName");
assertThat(displayName.get("highlighted").asBoolean()).isTrue();
assertThat(displayName.get("fragments").get(0).asText()).contains("**Arthur**");
}
@Test
void shouldBeAbleToMarshalDifferentTypesOfQueryResultToJson() throws IOException {
Instant now = Instant.ofEpochMilli(Instant.now().toEpochMilli());
try (IndexWriter writer = writer()) {
writer.addDocument(typesDoc(21, 42L, true, now));
}
QueryResult result = query(Types.class, "intValue:21");
ObjectMapper mapper = new ObjectMapper();
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.registerModule(new JavaTimeModule());
JsonNode root = mapper.valueToTree(result);
JsonNode fields = root.get("hits").get(0).get("fields");
assertThat(fields.get("intValue").get("value").asInt()).isEqualTo(21);
assertThat(fields.get("longValue").get("value").asLong()).isEqualTo(42L);
assertThat(fields.get("boolValue").get("value").asBoolean()).isTrue();
assertThat(fields.get("instantValue").get("value").asText()).isEqualTo(now.toString());
}
private QueryResult query(Class<?> type, String queryString) throws IOException {
return query(type, queryString, null, null);
}
private QueryResult query(Class<?> type, String queryString, Integer start, Integer limit) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
lenient().when(opener.openForRead("default")).thenReturn(reader);
LuceneQueryBuilder builder = new LuceneQueryBuilder(
opener, "default", new StandardAnalyzer()
);
if (start != null) {
builder.start(start);
}
if (limit != null) {
builder.limit(limit);
}
return builder.execute(type, queryString);
}
}
private IndexWriter writer() throws IOException {
IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer());
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
return new IndexWriter(directory, config);
}
private Document simpleDoc(String content) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, Simple.class.getName(), Field.Store.YES));
return document;
}
private Document permissionDoc(String content, String permission) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, Simple.class.getName(), Field.Store.YES));
document.add(new StringField(FieldNames.PERMISSION, permission, Field.Store.YES));
return document;
}
private Document repositoryDoc(String content, String repository) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, Simple.class.getName(), Field.Store.YES));
document.add(new StringField(FieldNames.REPOSITORY, repository, Field.Store.YES));
return document;
}
private Document inetOrgPersonDoc(String firstName, String lastName, String displayName, String carLicense) {
Document document = new Document();
document.add(new TextField("firstName", firstName, Field.Store.YES));
document.add(new TextField("lastName", lastName, Field.Store.YES));
document.add(new TextField("displayName", displayName, Field.Store.YES));
document.add(new TextField("carLicense", carLicense, Field.Store.YES));
document.add(new StringField(FieldNames.ID, lastName, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, InetOrgPerson.class.getName(), Field.Store.YES));
return document;
}
private Document personDoc(String lastName) {
Document document = new Document();
document.add(new TextField("lastName", lastName, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, Person.class.getName(), Field.Store.YES));
return document;
}
private Document typesDoc(int intValue, long longValue, boolean boolValue, Instant instantValue) {
Document document = new Document();
document.add(new IntPoint("intValue", intValue));
document.add(new StoredField("intValue", intValue));
document.add(new LongPoint("longValue", longValue));
document.add(new StoredField("longValue", longValue));
document.add(new StringField("boolValue", String.valueOf(boolValue), Field.Store.YES));
document.add(new LongPoint("instantValue", instantValue.toEpochMilli()));
document.add(new StoredField("instantValue", instantValue.toEpochMilli()));
document.add(new StringField(FieldNames.TYPE, Types.class.getName(), Field.Store.YES));
return document;
}
static class Types {
@Indexed
private Integer intValue;
@Indexed
private long longValue;
@Indexed
private boolean boolValue;
@Indexed
private Instant instantValue;
}
static class Person {
@Indexed(defaultQuery = true)
private String lastName;
}
static class InetOrgPerson extends Person {
@Indexed(defaultQuery = true, boost = 2f)
private String firstName;
@Indexed(defaultQuery = true, highlighted = true)
private String displayName;
@Indexed
private String carLicense;
}
static class Simple {
@Indexed(defaultQuery = true)
private String content;
}
}