Return separate links for searchable types instead of single templated link (#1733)

The search link of the index resource is now an array of links instead of single templated link.
The array contains one link for each searchable type.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Sebastian Sdorra
2021-07-21 10:07:41 +02:00
committed by GitHub
parent 8ba93422a2
commit 39d2f12b66
23 changed files with 443 additions and 98 deletions

View File

@@ -37,6 +37,8 @@ import sonia.scm.group.GroupPermissions;
import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.initialization.InitializationStep;
import sonia.scm.plugin.PluginPermissions;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SearchableType;
import sonia.scm.security.AnonymousMode;
import sonia.scm.security.Authentications;
import sonia.scm.security.PermissionPermissions;
@@ -45,9 +47,11 @@ import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
import java.util.List;
import java.util.stream.Collectors;
import static de.otto.edison.hal.Embedded.embeddedBuilder;
import static de.otto.edison.hal.Link.link;
import static de.otto.edison.hal.Link.linkBuilder;
public class IndexDtoGenerator extends HalAppenderMapper {
@@ -55,13 +59,19 @@ public class IndexDtoGenerator extends HalAppenderMapper {
private final SCMContextProvider scmContextProvider;
private final ScmConfiguration configuration;
private final InitializationFinisher initializationFinisher;
private final SearchEngine searchEngine;
@Inject
public IndexDtoGenerator(ResourceLinks resourceLinks, SCMContextProvider scmContextProvider, ScmConfiguration configuration, InitializationFinisher initializationFinisher) {
public IndexDtoGenerator(ResourceLinks resourceLinks,
SCMContextProvider scmContextProvider,
ScmConfiguration configuration,
InitializationFinisher initializationFinisher,
SearchEngine searchEngine) {
this.resourceLinks = resourceLinks;
this.scmContextProvider = scmContextProvider;
this.configuration = configuration;
this.initializationFinisher = initializationFinisher;
this.searchEngine = searchEngine;
}
public IndexDto generate() {
@@ -132,7 +142,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.single(link("repositoryRoles", resourceLinks.repositoryRoleCollection().self()));
builder.single(link("importLog", resourceLinks.repository().importLog("IMPORT_LOG_ID").replace("IMPORT_LOG_ID", "{logId}")));
builder.single(link("search", resourceLinks.search().search("INDEXED_TYPE").replace("INDEXED_TYPE", "{type}")));
builder.array(searchLinks());
} else {
builder.single(link("login", resourceLinks.authentication().jsonLogin()));
}
@@ -141,6 +151,15 @@ public class IndexDtoGenerator extends HalAppenderMapper {
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion());
}
private List<Link> searchLinks() {
return searchEngine.getSearchableTypes().stream()
.map(SearchableType::getName)
.map(typeName ->
linkBuilder("search", resourceLinks.search().query(typeName)).withName(typeName).build()
)
.collect(Collectors.toList());
}
private IndexDto handleInitialization(Links.Builder builder, Embedded.Builder embeddedBuilder) {
Links.Builder initializationLinkBuilder = Links.linkingTo();
Embedded.Builder initializationEmbeddedBuilder = embeddedBuilder();

View File

@@ -1125,8 +1125,8 @@ class ResourceLinks {
this.searchLinkBuilder = new LinkBuilder(pathInfo, SearchResource.class);
}
public String search(String type) {
return searchLinkBuilder.method("search").parameters(type).href();
public String query(String type) {
return searchLinkBuilder.method("query").parameters(type).href();
}
}

View File

@@ -61,7 +61,7 @@ public class SearchResource {
}
@GET
@Path("{type}")
@Path("query/{type}")
@Produces(VndMediaType.QUERY_RESULT)
@Operation(
summary = "Query result",
@@ -98,7 +98,7 @@ public class SearchResource {
name = "pageSize",
description = "The maximum number of results per page (defaults to 10)"
)
public QueryResultDto search(@Valid @BeanParam SearchParameters params) {
public QueryResultDto query(@Valid @BeanParam SearchParameters params) {
QueryResult result = engine.search(IndexNames.DEFAULT)
.start(params.getPage() * params.getPageSize())
.limit(params.getPageSize())

View File

@@ -51,7 +51,7 @@ public class LuceneIndex implements Index {
@Override
public void store(Id id, String permission, Object object) {
SearchableType type = resolver.resolve(object);
LuceneSearchableType type = resolver.resolve(object);
String uid = createUid(id, type);
Document document = type.getTypeConverter().convert(object);
try {
@@ -68,7 +68,7 @@ public class LuceneIndex implements Index {
}
}
private String createUid(Id id, SearchableType type) {
private String createUid(Id id, LuceneSearchableType type) {
return id.asString() + "/" + type.getName();
}
@@ -78,7 +78,7 @@ public class LuceneIndex implements Index {
@Override
public void delete(Id id, Class<?> type) {
SearchableType searchableType = resolver.resolve(type);
LuceneSearchableType searchableType = resolver.resolve(type);
try {
writer.deleteDocuments(new Term(UID, createUid(id, searchableType)));
} catch (IOException e) {
@@ -97,7 +97,7 @@ public class LuceneIndex implements Index {
@Override
public void deleteByType(Class<?> type) {
SearchableType searchableType = resolver.resolve(type);
LuceneSearchableType searchableType = resolver.resolve(type);
deleteByTypeName(searchableType.getName());
}

View File

@@ -40,6 +40,7 @@ import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.TopScoreDocCollector;
import org.apache.lucene.search.WildcardQuery;
import org.apache.lucene.search.highlight.InvalidTokenOffsetsException;
import org.apache.shiro.SecurityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -73,7 +74,10 @@ public class LuceneQueryBuilder extends QueryBuilder {
protected QueryResult execute(QueryParams queryParams) {
String queryString = Strings.nullToEmpty(queryParams.getQueryString());
SearchableType searchableType = resolver.resolve(queryParams.getType());
LuceneSearchableType searchableType = resolver.resolve(queryParams.getType());
searchableType.getPermission().ifPresent(
permission -> SecurityUtils.getSubject().checkPermission(permission)
);
Query parsedQuery = createQuery(searchableType, queryParams, queryString);
Query query = Queries.filter(parsedQuery, searchableType, queryParams);
@@ -105,7 +109,7 @@ public class LuceneQueryBuilder extends QueryBuilder {
return topScoreCollector.topDocs(queryParams.getStart(), queryParams.getLimit());
}
private Query createQuery(SearchableType searchableType, QueryParams queryParams, String queryString) {
private Query createQuery(LuceneSearchableType searchableType, QueryParams queryParams, String queryString) {
try {
if (queryString.contains(":")) {
return createExpertQuery(searchableType, queryParams);
@@ -116,14 +120,14 @@ public class LuceneQueryBuilder extends QueryBuilder {
}
}
private Query createExpertQuery(SearchableType searchableType, QueryParams queryParams) throws QueryNodeException {
private Query createExpertQuery(LuceneSearchableType searchableType, QueryParams queryParams) throws QueryNodeException {
StandardQueryParser parser = new StandardQueryParser(analyzer);
parser.setPointsConfigMap(searchableType.getPointsConfig());
return parser.parse(queryParams.getQueryString(), "");
}
public Query createBestGuessQuery(SearchableType searchableType, QueryBuilder.QueryParams queryParams) {
public Query createBestGuessQuery(LuceneSearchableType searchableType, QueryBuilder.QueryParams queryParams) {
String[] fieldNames = searchableType.getFieldNames();
if (fieldNames == null || fieldNames.length == 0) {
throw new NoDefaultQueryFieldsFoundException(searchableType.getType());

View File

@@ -24,19 +24,35 @@
package sonia.scm.search;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import javax.inject.Inject;
import java.util.Collection;
import java.util.stream.Collectors;
public class LuceneSearchEngine implements SearchEngine {
private final SearchableTypeResolver resolver;
private final LuceneIndexFactory indexFactory;
private final LuceneQueryBuilderFactory queryBuilderFactory;
@Inject
public LuceneSearchEngine(LuceneIndexFactory indexFactory, LuceneQueryBuilderFactory queryBuilderFactory) {
public LuceneSearchEngine(SearchableTypeResolver resolver, LuceneIndexFactory indexFactory, LuceneQueryBuilderFactory queryBuilderFactory) {
this.resolver = resolver;
this.indexFactory = indexFactory;
this.queryBuilderFactory = queryBuilderFactory;
}
@Override
public Collection<SearchableType> getSearchableTypes() {
Subject subject = SecurityUtils.getSubject();
return resolver.getSearchableTypes()
.stream()
.filter(type -> type.getPermission().map(subject::isPermitted).orElse(true))
.collect(Collectors.toList());
}
@Override
public Index getOrCreate(String name, IndexOptions options) {
return indexFactory.create(name, options);

View File

@@ -29,42 +29,41 @@ import lombok.Value;
import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Value
public class SearchableType {
public class LuceneSearchableType implements SearchableType {
private static final float DEFAULT_BOOST = 1f;
Class<?> type;
String name;
String[] fieldNames;
Map<String,Float> boosts;
Map<String, PointsConfig> pointsConfig;
String permission;
List<SearchableField> fields;
String[] fieldNames;
Map<String, Float> boosts;
Map<String, PointsConfig> pointsConfig;
TypeConverter typeConverter;
SearchableType(Class<?> type,
String[] fieldNames,
Map<String, Float> boosts,
Map<String, PointsConfig> pointsConfig,
List<SearchableField> fields,
TypeConverter typeConverter) {
LuceneSearchableType(Class<?> type, IndexedType annotation, List<SearchableField> fields) {
this.type = type;
this.name = name(type);
this.fieldNames = fieldNames;
this.boosts = Collections.unmodifiableMap(boosts);
this.pointsConfig = Collections.unmodifiableMap(pointsConfig);
this.fields = Collections.unmodifiableList(fields);
this.typeConverter = typeConverter;
this.name = name(type, annotation);
this.permission = Strings.emptyToNull(annotation.permission());
this.fields = fields;
this.fieldNames = fieldNames(fields);
this.boosts = boosts(fields);
this.pointsConfig = pointsConfig(fields);
this.typeConverter = TypeConverters.create(type);
}
private String name(Class<?> type) {
IndexedType annotation = type.getAnnotation(IndexedType.class);
if (annotation == null) {
throw new IllegalArgumentException(
type.getName() + " has no " + IndexedType.class.getSimpleName() + " annotation"
);
}
public Optional<String> getPermission() {
return Optional.ofNullable(permission);
}
private String name(Class<?> type, IndexedType annotation) {
String nameFromAnnotation = annotation.value();
if (Strings.isNullOrEmpty(nameFromAnnotation)) {
String simpleName = type.getSimpleName();
@@ -72,4 +71,32 @@ public class SearchableType {
}
return nameFromAnnotation;
}
private String[] fieldNames(List<SearchableField> fields) {
return fields.stream()
.filter(SearchableField::isDefaultQuery)
.map(SearchableField::getName)
.toArray(String[]::new);
}
private Map<String, Float> boosts(List<SearchableField> fields) {
Map<String, Float> map = new HashMap<>();
for (SearchableField field : fields) {
if (field.isDefaultQuery() && field.getBoost() != DEFAULT_BOOST) {
map.put(field.getName(), field.getBoost());
}
}
return Collections.unmodifiableMap(map);
}
private Map<String, PointsConfig> pointsConfig(List<SearchableField> fields) {
Map<String, PointsConfig> map = new HashMap<>();
for (SearchableField field : fields) {
PointsConfig config = field.getPointsConfig();
if (config != null) {
map.put(field.getName(), config);
}
}
return Collections.unmodifiableMap(map);
}
}

View File

@@ -36,7 +36,7 @@ final class Queries {
private Queries() {
}
private static Query typeQuery(SearchableType type) {
private static Query typeQuery(LuceneSearchableType type) {
return new TermQuery(new Term(FieldNames.TYPE, type.getName()));
}
@@ -44,7 +44,7 @@ final class Queries {
return new TermQuery(new Term(FieldNames.REPOSITORY, repositoryId));
}
static Query filter(Query query, SearchableType searchableType, QueryBuilder.QueryParams params) {
static Query filter(Query query, LuceneSearchableType searchableType, QueryBuilder.QueryParams params) {
BooleanQuery.Builder builder = new BooleanQuery.Builder()
.add(query, MUST)
.add(typeQuery(searchableType), MUST);

View File

@@ -50,9 +50,9 @@ public class QueryResultFactory {
private final Analyzer analyzer;
private final Highlighter highlighter;
private final IndexSearcher searcher;
private final SearchableType searchableType;
private final LuceneSearchableType searchableType;
public QueryResultFactory(Analyzer analyzer, IndexSearcher searcher, SearchableType searchableType, Query query) {
public QueryResultFactory(Analyzer analyzer, IndexSearcher searcher, LuceneSearchableType searchableType, Query query) {
this.analyzer = analyzer;
this.searcher = searcher;
this.searchableType = searchableType;
@@ -61,7 +61,7 @@ public class QueryResultFactory {
private Highlighter createHighlighter(Query query) {
return new Highlighter(
new SimpleHTMLFormatter("**", "**"),
new SimpleHTMLFormatter("<>", "</>"),
new QueryScorer(query)
);
}

View File

@@ -31,6 +31,8 @@ import javax.annotation.Nonnull;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@@ -44,7 +46,7 @@ import static sonia.scm.NotFoundException.notFound;
@Singleton
class SearchableTypeResolver {
private final Map<Class<?>, SearchableType> classToSearchableType = new HashMap<>();
private final Map<Class<?>, LuceneSearchableType> classToSearchableType = new HashMap<>();
private final Map<String, Class<?>> nameToClass = new HashMap<>();
@Inject
@@ -62,26 +64,30 @@ class SearchableTypeResolver {
fillMaps(convert(indexedTypes));
}
private void fillMaps(Iterable<SearchableType> types) {
for (SearchableType type : types) {
private void fillMaps(Iterable<LuceneSearchableType> types) {
for (LuceneSearchableType type : types) {
classToSearchableType.put(type.getType(), type);
nameToClass.put(type.getName(), type.getType());
}
}
@Nonnull
private Set<SearchableType> convert(Iterable<Class<?>> indexedTypes) {
private Set<LuceneSearchableType> convert(Iterable<Class<?>> indexedTypes) {
return StreamSupport.stream(indexedTypes.spliterator(), false)
.map(SearchableTypes::create)
.collect(Collectors.toSet());
}
public SearchableType resolve(Object object) {
public Collection<LuceneSearchableType> getSearchableTypes() {
return Collections.unmodifiableCollection(classToSearchableType.values());
}
public LuceneSearchableType resolve(Object object) {
return resolve(object.getClass());
}
public SearchableType resolve(Class<?> type) {
SearchableType searchableType = classToSearchableType.get(type);
public LuceneSearchableType resolve(Class<?> type) {
LuceneSearchableType searchableType = classToSearchableType.get(type);
if (searchableType == null) {
throw notFound(entity("type", type.getName()));
}

View File

@@ -24,46 +24,25 @@
package sonia.scm.search;
import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
final class SearchableTypes {
private static final float DEFAULT_BOOST = 1f;
private SearchableTypes() {
}
static SearchableType create(Class<?> type) {
static LuceneSearchableType create(Class<?> type) {
List<SearchableField> fields = new ArrayList<>();
collectFields(type, fields);
return createSearchableType(type, fields);
}
private static SearchableType createSearchableType(Class<?> type, List<SearchableField> fields) {
String[] fieldsNames = fields.stream()
.filter(SearchableField::isDefaultQuery)
.map(SearchableField::getName)
.toArray(String[]::new);
Map<String, Float> boosts = new HashMap<>();
Map<String, PointsConfig> pointsConfig = new HashMap<>();
for (SearchableField field : fields) {
if (field.isDefaultQuery() && field.getBoost() != DEFAULT_BOOST) {
boosts.put(field.getName(), field.getBoost());
}
PointsConfig config = field.getPointsConfig();
if (config != null) {
pointsConfig.put(field.getName(), config);
}
IndexedType annotation = type.getAnnotation(IndexedType.class);
if (annotation == null) {
throw new IllegalArgumentException(
type.getName() + " has no " + IndexedType.class.getSimpleName() + " annotation"
);
}
return new SearchableType(type, fieldsNames, boosts, pointsConfig, fields, TypeConverters.create(type));
collectFields(type, fields);
return new LuceneSearchableType(type, annotation, fields);
}
private static void collectFields(Class<?> type, List<SearchableField> fields) {