diff --git a/gradle/changelog/query_with_hypen.yaml b/gradle/changelog/query_with_hypen.yaml new file mode 100644 index 0000000000..d2a001fbab --- /dev/null +++ b/gradle/changelog/query_with_hypen.yaml @@ -0,0 +1,2 @@ +- type: Fixed + description: Fixed search queries containing hypens ([#1743](https://github.com/scm-manager/scm-manager/issues/1743) and [#1753](https://github.com/scm-manager/scm-manager/pull/1753)) diff --git a/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java b/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java index 101a409181..00b1c74af8 100644 --- a/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java +++ b/scm-webapp/src/main/java/sonia/scm/search/LuceneQueryBuilder.java @@ -26,6 +26,8 @@ package sonia.scm.search; import com.google.common.base.Strings; import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; import org.apache.lucene.index.IndexReader; import org.apache.lucene.index.Term; import org.apache.lucene.queryparser.flexible.core.QueryNodeException; @@ -130,7 +132,7 @@ public class LuceneQueryBuilder extends QueryBuilder { return createExpertQuery(searchableType, queryParams); } return createBestGuessQuery(searchableType, queryParams); - } catch (QueryNodeException ex) { + } catch (QueryNodeException | IOException ex) { throw new QueryParseException(queryString, "failed to parse query", ex); } } @@ -142,15 +144,25 @@ public class LuceneQueryBuilder extends QueryBuilder { return parser.parse(queryParams.getQueryString(), ""); } - public Query createBestGuessQuery(LuceneSearchableType searchableType, QueryBuilder.QueryParams queryParams) { + public Query createBestGuessQuery(LuceneSearchableType searchableType, QueryBuilder.QueryParams queryParams) throws QueryNodeException, IOException { String[] fieldNames = searchableType.getFieldNames(); if (fieldNames == null || fieldNames.length == 0) { throw new NoDefaultQueryFieldsFoundException(searchableType.getType()); } + + String queryString = queryParams.getQueryString().toLowerCase(Locale.ENGLISH); + boolean hasWildcard = containsWildcard(queryString); + BooleanQuery.Builder builder = new BooleanQuery.Builder(); for (String fieldName : fieldNames) { - Term term = new Term(fieldName, appendWildcardIfNotAlreadyUsed(queryParams)); - WildcardQuery query = new WildcardQuery(term); + Query query; + if (!hasWildcard) { + query = createWildcardQuery(fieldName, queryString); + } else { + StandardQueryParser parser = new StandardQueryParser(analyzer); + parser.setPointsConfigMap(searchableType.getPointsConfig()); + query = parser.parse(queryParams.getQueryString(), fieldName); + } Float boost = searchableType.getBoosts().get(fieldName); if (boost != null) { @@ -162,13 +174,24 @@ public class LuceneQueryBuilder extends QueryBuilder { return builder.build(); } - @Nonnull - private String appendWildcardIfNotAlreadyUsed(QueryParams queryParams) { - String queryString = queryParams.getQueryString().toLowerCase(Locale.ENGLISH); - if (!queryString.contains("?") && !queryString.contains("*")) { - queryString += "*"; + private Query createWildcardQuery(String fieldName, String queryString) throws IOException { + try (TokenStream tokenStream = analyzer.tokenStream(fieldName, queryString)) { + CharTermAttribute attribute = tokenStream.addAttribute(CharTermAttribute.class); + + BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder(); + + tokenStream.reset(); + while (tokenStream.incrementToken()) { + String token = attribute.toString(); + queryBuilder.add(new WildcardQuery(new Term(fieldName, token + "*")), BooleanClause.Occur.SHOULD); + } + + return queryBuilder.build(); } - return queryString; + } + + private boolean containsWildcard(String queryString) { + return queryString.contains("?") || queryString.contains("*"); } @FunctionalInterface diff --git a/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java b/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java index 8348a9230c..8f690d72ee 100644 --- a/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/search/LuceneQueryBuilderTest.java @@ -63,6 +63,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; +@SuppressWarnings("java:S5976") @SubjectAware(value = "trillian", permissions = "abc") @ExtendWith({MockitoExtension.class, ShiroExtension.class}) class LuceneQueryBuilderTest { @@ -107,6 +108,37 @@ class LuceneQueryBuilderTest { assertThat(result.getTotalHits()).isOne(); } + @Test + void shouldMatchWithHyphen() throws IOException { + try (IndexWriter writer = writer()) { + writer.addDocument(personDoc("Trillian-McMillan")); + } + + QueryResult result = query(Person.class, "Trillian-McMi"); + assertThat(result.getTotalHits()).isOne(); + } + + @Test + void shouldMatchQueryWithMultipleFieldsAndHyphen() throws IOException { + try (IndexWriter writer = writer()) { + writer.addDocument(inetOrgPersonDoc("Trillian", "McMillan", "Trillian McMillan", "abc")); + } + + QueryResult result = query(InetOrgPerson.class, "Trillian-McMi"); + assertThat(result.getTotalHits()).isOne(); + } + + + @Test + void shouldMatchExpertQueryWithHyphen() throws IOException { + try (IndexWriter writer = writer()) { + writer.addDocument(personDoc("Trillian-McMillan")); + } + + QueryResult result = query(Person.class, "lastName:Trillian-McMi"); + assertThat(result.getTotalHits()).isOne(); + } + @Test @SuppressWarnings("java:S5976") void shouldNotAppendWildcardIfStarIsUsed() throws IOException {