mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-05-06 18:26:15 +02:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
238
scm-webapp/src/test/java/sonia/scm/search/LuceneIndexTest.java
Normal file
238
scm-webapp/src/test/java/sonia/scm/search/LuceneIndexTest.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user