Browse Source
Using TextQuery and TextCriteria allows creation of queries using $text $search.
{ $meta : “textScore” } can be included using TextQuery.includeScore. As the fieldname used for textScore must not be fixed to “score” one can use the overload taking the fieldname to override the default.
Original pull request: #198.
pull/200/head
10 changed files with 1424 additions and 2 deletions
@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query.text; |
||||
|
||||
/** |
||||
* A {@link Term} defines one or multiple words {@link Type#WORD} or phrases {@link Type#PHRASE} to be used in the |
||||
* context of full text search. |
||||
* |
||||
* @author Christoph Strobl |
||||
* @since 1.6 |
||||
*/ |
||||
public class Term { |
||||
|
||||
enum Type { |
||||
WORD, PHRASE; |
||||
} |
||||
|
||||
private final Type type; |
||||
private final String raw; |
||||
private boolean negated; |
||||
|
||||
/** |
||||
* Creates a new {@link Term} of {@link Type#WORD}. |
||||
* |
||||
* @param raw |
||||
*/ |
||||
public Term(String raw) { |
||||
this(raw, Type.WORD); |
||||
} |
||||
|
||||
/** |
||||
* Creates a new {@link Term} of given {@link Type}. |
||||
* |
||||
* @param raw |
||||
* @param type defaulted to {@link Type#WORD} if {@literal null}. |
||||
*/ |
||||
public Term(String raw, Type type) { |
||||
this.raw = raw; |
||||
this.type = type == null ? Type.WORD : type; |
||||
} |
||||
|
||||
/** |
||||
* Negates the term. |
||||
* |
||||
* @return |
||||
*/ |
||||
public Term negate() { |
||||
this.negated = true; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* @return return true if term is negated. |
||||
*/ |
||||
public boolean isNegated() { |
||||
return negated; |
||||
} |
||||
|
||||
/** |
||||
* @return type of term. Never {@literal null}. |
||||
*/ |
||||
public Type getType() { |
||||
return type; |
||||
} |
||||
|
||||
/** |
||||
* Get formatted representation of term. |
||||
* |
||||
* @return |
||||
*/ |
||||
public String getFormatted() { |
||||
|
||||
String formatted = Type.PHRASE.equals(type) ? quotePhrase(raw) : raw; |
||||
return negated ? negateRaw(formatted) : formatted; |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return getFormatted(); |
||||
} |
||||
|
||||
protected String quotePhrase(String raw) { |
||||
return "\"" + raw + "\""; |
||||
} |
||||
|
||||
protected String negateRaw(String raw) { |
||||
return "-" + raw; |
||||
} |
||||
} |
||||
@ -0,0 +1,223 @@
@@ -0,0 +1,223 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query.text; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.Iterator; |
||||
import java.util.List; |
||||
|
||||
import org.springframework.data.mongodb.core.query.Criteria; |
||||
import org.springframework.data.mongodb.core.query.CriteriaDefinition; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.CollectionUtils; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
import com.mongodb.BasicDBObject; |
||||
import com.mongodb.BasicDBObjectBuilder; |
||||
import com.mongodb.DBObject; |
||||
|
||||
/** |
||||
* Implementation of {@link CriteriaDefinition} to be used for full text search . |
||||
* |
||||
* @author Christoph Strobl |
||||
* @since 1.6 |
||||
*/ |
||||
public class TextCriteria extends Criteria { |
||||
|
||||
private String language; |
||||
private List<Term> terms; |
||||
|
||||
public TextCriteria() { |
||||
this.terms = new ArrayList<Term>(); |
||||
} |
||||
|
||||
/* |
||||
* (non-Javadoc) |
||||
* @see org.springframework.data.mongodb.core.query.CriteriaDefinition#getCriteriaObject() |
||||
*/ |
||||
@Override |
||||
public DBObject getCriteriaObject() { |
||||
|
||||
BasicDBObjectBuilder builder = new BasicDBObjectBuilder(); |
||||
|
||||
if (StringUtils.hasText(language)) { |
||||
builder.add("$language", language); |
||||
} |
||||
|
||||
if (!CollectionUtils.isEmpty(terms)) { |
||||
builder.add("$search", join(terms.iterator())); |
||||
} |
||||
|
||||
return new BasicDBObject("$text", builder.get()); |
||||
} |
||||
|
||||
/** |
||||
* @param words |
||||
* @return |
||||
*/ |
||||
public TextCriteria matchingAny(String... words) { |
||||
|
||||
for (String word : words) { |
||||
matching(word); |
||||
} |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Add given {@link Term} to criteria. |
||||
* |
||||
* @param term must not be null. |
||||
*/ |
||||
public void matching(Term term) { |
||||
|
||||
Assert.notNull(term, "Term to add must not be null."); |
||||
this.terms.add(term); |
||||
} |
||||
|
||||
private void notMatching(Term term) { |
||||
matching(term.negate()); |
||||
} |
||||
|
||||
/** |
||||
* @param term |
||||
* @return |
||||
*/ |
||||
public TextCriteria matching(String term) { |
||||
|
||||
if (StringUtils.hasText(term)) { |
||||
matching(new Term(term)); |
||||
} |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* @param term |
||||
* @return |
||||
*/ |
||||
public TextCriteria notMatching(String term) { |
||||
|
||||
if (StringUtils.hasText(term)) { |
||||
notMatching(new Term(term, Term.Type.WORD)); |
||||
} |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* @param words |
||||
* @return |
||||
*/ |
||||
public TextCriteria notMatchingAny(String... words) { |
||||
|
||||
for (String word : words) { |
||||
notMatching(word); |
||||
} |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Given value will treated as a single phrase. |
||||
* |
||||
* @param phrase |
||||
* @return |
||||
*/ |
||||
public TextCriteria notMatchingPhrase(String phrase) { |
||||
|
||||
if (StringUtils.hasText(phrase)) { |
||||
notMatching(new Term(phrase, Term.Type.PHRASE)); |
||||
} |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Given value will treated as a single phrase. |
||||
* |
||||
* @param phrase |
||||
* @return |
||||
*/ |
||||
public TextCriteria matchingPhrase(String phrase) { |
||||
|
||||
if (StringUtils.hasText(phrase)) { |
||||
matching(new Term(phrase, Term.Type.PHRASE)); |
||||
} |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* @return |
||||
*/ |
||||
public static TextCriteria forDefaultLanguage() { |
||||
return new TextCriteriaBuilder().build(); |
||||
} |
||||
|
||||
/** |
||||
* For a full list of supported languages see the mongdodb reference manual for <a |
||||
* href="http://docs.mongodb.org/manual/reference/text-search-languages/">Text Search Languages</a>. |
||||
* |
||||
* @param language |
||||
* @return |
||||
*/ |
||||
public static TextCriteria forLanguage(String language) { |
||||
return new TextCriteriaBuilder().withLanguage(language).build(); |
||||
} |
||||
|
||||
private static String join(Iterator<Term> iterator) { |
||||
|
||||
Term first = iterator.next(); |
||||
if (!iterator.hasNext()) { |
||||
return first.getFormatted(); |
||||
} |
||||
|
||||
StringBuilder buf = new StringBuilder(256); |
||||
if (first != null) { |
||||
buf.append(first); |
||||
} |
||||
|
||||
while (iterator.hasNext()) { |
||||
buf.append(' '); |
||||
Term obj = iterator.next(); |
||||
if (obj != null) { |
||||
buf.append(obj.getFormatted()); |
||||
} |
||||
} |
||||
|
||||
return buf.toString(); |
||||
} |
||||
|
||||
public static class TextCriteriaBuilder { |
||||
|
||||
private TextCriteria instance; |
||||
|
||||
public TextCriteriaBuilder() { |
||||
this.instance = new TextCriteria(); |
||||
} |
||||
|
||||
public TextCriteriaBuilder withLanguage(String language) { |
||||
this.instance.language = language; |
||||
return this; |
||||
} |
||||
|
||||
public TextCriteria build() { |
||||
return this.instance; |
||||
} |
||||
|
||||
} |
||||
|
||||
@Override |
||||
public String getKey() { |
||||
return "$text"; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query.text; |
||||
|
||||
import java.util.Locale; |
||||
|
||||
import org.springframework.data.mongodb.core.query.Query; |
||||
|
||||
import com.mongodb.BasicDBObject; |
||||
import com.mongodb.DBObject; |
||||
|
||||
/** |
||||
* {@link Query} implementation to be used to for performing full text searches. |
||||
* |
||||
* @author Christoph Strobl |
||||
* @since 1.6 |
||||
*/ |
||||
public class TextQuery extends Query { |
||||
|
||||
private final String DEFAULT_SCORE_FIELD_FIELDNAME = "score"; |
||||
private final DBObject META_TEXT_SCORE = new BasicDBObject("$meta", "textScore"); |
||||
|
||||
private String scoreFieldName = DEFAULT_SCORE_FIELD_FIELDNAME; |
||||
private boolean includeScore = false; |
||||
private boolean sortByScore = false; |
||||
|
||||
/** |
||||
* Creates new {@link TextQuery} using the the given {@code wordsAndPhrases} with {@link TextCriteria} |
||||
* |
||||
* @param wordsAndPhrases |
||||
* @see TextCriteria#matching(String) |
||||
*/ |
||||
public TextQuery(String wordsAndPhrases) { |
||||
super(TextCriteria.forDefaultLanguage().matching(wordsAndPhrases)); |
||||
} |
||||
|
||||
/** |
||||
* Creates new {@link TextQuery} in {@code language}. <br /> |
||||
* For a full list of supported languages see the mongdodb reference manual for <a |
||||
* href="http://docs.mongodb.org/manual/reference/text-search-languages/">Text Search Languages</a>. |
||||
* |
||||
* @param wordsAndPhrases |
||||
* @param language |
||||
* @see TextCriteria#forLanguage(String) |
||||
* @see TextCriteria#matching(String) |
||||
*/ |
||||
public TextQuery(String wordsAndPhrases, String language) { |
||||
super(TextCriteria.forLanguage(language).matching(wordsAndPhrases)); |
||||
} |
||||
|
||||
/** |
||||
* Creates new {@link TextQuery} using the {@code locale}s language.<br /> |
||||
* For a full list of supported languages see the mongdodb reference manual for <a |
||||
* href="http://docs.mongodb.org/manual/reference/text-search-languages/">Text Search Languages</a>. |
||||
* |
||||
* @param wordsAndPhrases |
||||
* @param locale |
||||
*/ |
||||
public TextQuery(String wordsAndPhrases, Locale locale) { |
||||
this(wordsAndPhrases, locale != null ? locale.getLanguage() : (String) null); |
||||
} |
||||
|
||||
/** |
||||
* Creates new {@link TextQuery} for given {@link TextCriteria}. |
||||
* |
||||
* @param criteria. |
||||
*/ |
||||
public TextQuery(TextCriteria criteria) { |
||||
super(criteria); |
||||
} |
||||
|
||||
/** |
||||
* Creates new {@link TextQuery} searching for given {@link TextCriteria}. |
||||
* |
||||
* @param criteria |
||||
* @return |
||||
*/ |
||||
public static TextQuery queryText(TextCriteria criteria) { |
||||
return new TextQuery(criteria); |
||||
} |
||||
|
||||
/** |
||||
* Add sorting by text score. Will also add text score to returned fields. |
||||
* |
||||
* @see TextQuery#includeScore() |
||||
* @return |
||||
*/ |
||||
public TextQuery sortByScore() { |
||||
|
||||
this.includeScore(); |
||||
this.sortByScore = true; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Add field {@literal score} holding the documents textScore to the returned fields. |
||||
* |
||||
* @return |
||||
*/ |
||||
public TextQuery includeScore() { |
||||
|
||||
this.includeScore = true; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Include text search document score in returned fields using the given fieldname. |
||||
* |
||||
* @param fieldname |
||||
* @return |
||||
*/ |
||||
public TextQuery includeScore(String fieldname) { |
||||
|
||||
setScoreFieldName(fieldname); |
||||
includeScore(); |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Set the fieldname used for scoring. |
||||
* |
||||
* @param fieldName |
||||
*/ |
||||
public void setScoreFieldName(String fieldName) { |
||||
this.scoreFieldName = fieldName; |
||||
} |
||||
|
||||
/** |
||||
* Get the fieldname used for scoring |
||||
* |
||||
* @return |
||||
*/ |
||||
public String getScoreFieldName() { |
||||
return scoreFieldName; |
||||
} |
||||
|
||||
@Override |
||||
public DBObject getFieldsObject() { |
||||
|
||||
if (!this.includeScore) { |
||||
return super.getFieldsObject(); |
||||
} |
||||
|
||||
DBObject fields = super.getFieldsObject(); |
||||
if (fields == null) { |
||||
fields = new BasicDBObject(); |
||||
} |
||||
fields.put(getScoreFieldName(), META_TEXT_SCORE); |
||||
return fields; |
||||
} |
||||
|
||||
@Override |
||||
public DBObject getSortObject() { |
||||
|
||||
DBObject sort = new BasicDBObject(); |
||||
if (this.sortByScore) { |
||||
sort.put(getScoreFieldName(), META_TEXT_SCORE); |
||||
} |
||||
if (super.getSortObject() != null) { |
||||
sort.putAll(super.getSortObject()); |
||||
} |
||||
return sort; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query; |
||||
|
||||
import org.hamcrest.Description; |
||||
import org.hamcrest.TypeSafeMatcher; |
||||
import org.hamcrest.core.IsEqual; |
||||
import org.springframework.data.domain.Sort.Direction; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
import com.mongodb.BasicDBObject; |
||||
import com.mongodb.DBObject; |
||||
|
||||
/** |
||||
* A {@link TypeSafeMatcher} that tests whether a given {@link Query} matches a query specification. |
||||
* |
||||
* @author Christoph Strobl |
||||
* @param <T> |
||||
*/ |
||||
public class IsQuery<T extends Query> extends TypeSafeMatcher<T> { |
||||
|
||||
protected DBObject query; |
||||
protected DBObject sort; |
||||
protected DBObject fields; |
||||
|
||||
private int skip; |
||||
private int limit; |
||||
private String hint; |
||||
|
||||
protected IsQuery() { |
||||
query = new BasicDBObject(); |
||||
sort = new BasicDBObject(); |
||||
} |
||||
|
||||
public static <T extends BasicQuery> IsQuery<T> isQuery() { |
||||
return new IsQuery<T>(); |
||||
} |
||||
|
||||
public IsQuery<T> limitingTo(int limit) { |
||||
this.limit = limit; |
||||
return this; |
||||
} |
||||
|
||||
public IsQuery<T> skippig(int skip) { |
||||
this.skip = skip; |
||||
return this; |
||||
} |
||||
|
||||
public IsQuery<T> providingHint(String hint) { |
||||
this.hint = hint; |
||||
return this; |
||||
} |
||||
|
||||
public IsQuery<T> includingField(String fieldname) { |
||||
|
||||
if (fields == null) { |
||||
fields = new BasicDBObject(); |
||||
} |
||||
fields.put(fieldname, 1); |
||||
|
||||
return this; |
||||
} |
||||
|
||||
public IsQuery<T> excludingField(String fieldname) { |
||||
|
||||
if (fields == null) { |
||||
fields = new BasicDBObject(); |
||||
} |
||||
fields.put(fieldname, -1); |
||||
|
||||
return this; |
||||
} |
||||
|
||||
public IsQuery<T> sortingBy(String fieldname, Direction direction) { |
||||
|
||||
sort.put(fieldname, Direction.ASC.equals(direction) ? 1 : -1); |
||||
|
||||
return this; |
||||
} |
||||
|
||||
@Override |
||||
public void describeTo(Description description) { |
||||
|
||||
BasicQuery expected = new BasicQuery(this.query, this.fields); |
||||
expected.setSortObject(sort); |
||||
expected.skip(this.skip); |
||||
expected.limit(this.limit); |
||||
|
||||
if (StringUtils.hasText(this.hint)) { |
||||
expected.withHint(this.hint); |
||||
} |
||||
|
||||
description.appendValue(expected); |
||||
} |
||||
|
||||
@Override |
||||
protected boolean matchesSafely(T item) { |
||||
|
||||
if (item == null) { |
||||
return false; |
||||
} |
||||
|
||||
if (!new IsEqual<DBObject>(query).matches(item.getQueryObject())) { |
||||
return false; |
||||
} |
||||
|
||||
if (!new IsEqual<DBObject>(sort).matches(item.getSortObject())) { |
||||
return false; |
||||
} |
||||
|
||||
if (!new IsEqual<DBObject>(fields).matches(item.getFieldsObject())) { |
||||
return false; |
||||
} |
||||
|
||||
if (!new IsEqual<String>(this.hint).matches(item.getHint())) { |
||||
return false; |
||||
} |
||||
|
||||
if (!new IsEqual<Integer>(this.skip).matches(item.getSkip())) { |
||||
return false; |
||||
} |
||||
|
||||
if (!new IsEqual<Integer>(this.limit).matches(item.getLimit())) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query.text; |
||||
|
||||
import org.hamcrest.TypeSafeMatcher; |
||||
import org.springframework.data.mongodb.core.query.IsQuery; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
import com.mongodb.BasicDBObject; |
||||
import com.mongodb.DBObject; |
||||
|
||||
/** |
||||
* A {@link TypeSafeMatcher} that tests whether a given {@link TextQuery} matches a query specification. |
||||
* |
||||
* @author Christoph Strobl |
||||
* @param <T> |
||||
*/ |
||||
public class IsTextQuery<T extends TextQuery> extends IsQuery<T> { |
||||
|
||||
private final String SCORE_DEFAULT_FIELDNAME = "score"; |
||||
private final DBObject META_TEXT_SCORE = new BasicDBObject("$meta", "textScore"); |
||||
|
||||
private String scoreFieldName = SCORE_DEFAULT_FIELDNAME; |
||||
|
||||
private IsTextQuery() { |
||||
super(); |
||||
} |
||||
|
||||
public static <T extends TextQuery> IsTextQuery<T> isTextQuery() { |
||||
return new IsTextQuery<T>(); |
||||
} |
||||
|
||||
public IsTextQuery<T> searchingFor(String term) { |
||||
appendTerm(term); |
||||
return this; |
||||
} |
||||
|
||||
public IsTextQuery<T> inLanguage(String language) { |
||||
appendLanguage(language); |
||||
return this; |
||||
} |
||||
|
||||
public IsTextQuery<T> returningScore() { |
||||
|
||||
if (fields == null) { |
||||
fields = new BasicDBObject(); |
||||
} |
||||
fields.put(scoreFieldName, META_TEXT_SCORE); |
||||
|
||||
return this; |
||||
} |
||||
|
||||
public IsTextQuery<T> returningScoreAs(String fieldname) { |
||||
|
||||
this.scoreFieldName = fieldname != null ? fieldname : SCORE_DEFAULT_FIELDNAME; |
||||
|
||||
return this.returningScore(); |
||||
} |
||||
|
||||
public IsTextQuery<T> sortingByScore() { |
||||
|
||||
sort.put(scoreFieldName, META_TEXT_SCORE); |
||||
|
||||
return this; |
||||
} |
||||
|
||||
private void appendLanguage(String language) { |
||||
|
||||
DBObject dbo = getOrCreateTextDbo(); |
||||
dbo.put("$language", language); |
||||
} |
||||
|
||||
private DBObject getOrCreateTextDbo() { |
||||
|
||||
DBObject dbo = (DBObject) query.get("$text"); |
||||
if (dbo == null) { |
||||
dbo = new BasicDBObject(); |
||||
} |
||||
|
||||
return dbo; |
||||
} |
||||
|
||||
private void appendTerm(String term) { |
||||
|
||||
DBObject dbo = getOrCreateTextDbo(); |
||||
String searchString = (String) dbo.get("$search"); |
||||
if (StringUtils.hasText(searchString)) { |
||||
searchString += (" " + term); |
||||
} else { |
||||
searchString = term; |
||||
} |
||||
dbo.put("$search", searchString); |
||||
query.put("$text", dbo); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query.text; |
||||
|
||||
import org.hamcrest.core.IsEqual; |
||||
import org.junit.Assert; |
||||
import org.junit.Test; |
||||
import org.springframework.data.mongodb.core.DBObjectTestUtils; |
||||
|
||||
import com.mongodb.BasicDBObject; |
||||
import com.mongodb.DBObject; |
||||
import com.mongodb.util.JSON; |
||||
|
||||
/** |
||||
* @author Christoph Strobl |
||||
*/ |
||||
public class TextCriteriaUnitTests { |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldNotHaveLanguageField() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forDefaultLanguage(); |
||||
Assert.assertThat(criteria.getCriteriaObject(), IsEqual.equalTo(searchObject("{ }"))); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldNotHaveLanguageForNonDefaultLanguageField() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forLanguage("spanish"); |
||||
Assert.assertThat(criteria.getCriteriaObject(), IsEqual.equalTo(searchObject("{ \"$language\" : \"spanish\" }"))); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldCreateSearchFieldForSingleTermCorrectly() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forDefaultLanguage().matching("cake"); |
||||
Assert.assertThat(criteria.getCriteriaObject(), IsEqual.equalTo(searchObject("{ \"$search\" : \"cake\" }"))); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldCreateSearchFieldCorrectlyForMultipleTermsCorrectly() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forDefaultLanguage().matchingAny("bake", "coffee", "cake"); |
||||
Assert.assertThat(criteria.getCriteriaObject(), |
||||
IsEqual.equalTo(searchObject("{ \"$search\" : \"bake coffee cake\" }"))); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldCreateSearchFieldForPhraseCorrectly() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forDefaultLanguage().matchingPhrase("coffee cake"); |
||||
Assert.assertThat(DBObjectTestUtils.getAsDBObject(criteria.getCriteriaObject(), "$text"), |
||||
IsEqual.<DBObject> equalTo(new BasicDBObject("$search", "\"coffee cake\""))); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldCreateNotFieldCorrectly() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forDefaultLanguage().notMatching("cake"); |
||||
Assert.assertThat(criteria.getCriteriaObject(), IsEqual.equalTo(searchObject("{ \"$search\" : \"-cake\" }"))); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldCreateSearchFieldCorrectlyForNotMultipleTermsCorrectly() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forDefaultLanguage().notMatchingAny("bake", "coffee", "cake"); |
||||
Assert.assertThat(criteria.getCriteriaObject(), |
||||
IsEqual.equalTo(searchObject("{ \"$search\" : \"-bake -coffee -cake\" }"))); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldCreateSearchFieldForNotPhraseCorrectly() { |
||||
|
||||
TextCriteria criteria = TextCriteria.forDefaultLanguage().notMatchingPhrase("coffee cake"); |
||||
Assert.assertThat(DBObjectTestUtils.getAsDBObject(criteria.getCriteriaObject(), "$text"), |
||||
IsEqual.<DBObject> equalTo(new BasicDBObject("$search", "-\"coffee cake\""))); |
||||
} |
||||
|
||||
private DBObject searchObject(String json) { |
||||
return new BasicDBObject("$text", JSON.parse(json)); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,375 @@
@@ -0,0 +1,375 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query.text; |
||||
|
||||
import static org.hamcrest.collection.IsCollectionWithSize.*; |
||||
import static org.hamcrest.collection.IsEmptyCollection.*; |
||||
import static org.hamcrest.collection.IsIterableContainingInOrder.*; |
||||
import static org.hamcrest.core.AnyOf.*; |
||||
import static org.hamcrest.core.IsCollectionContaining.*; |
||||
import static org.hamcrest.core.IsEqual.*; |
||||
import static org.junit.Assert.*; |
||||
import static org.springframework.data.mongodb.core.query.Criteria.*; |
||||
|
||||
import java.util.List; |
||||
|
||||
import org.junit.Before; |
||||
import org.junit.ClassRule; |
||||
import org.junit.Test; |
||||
import org.springframework.beans.factory.annotation.Autowired; |
||||
import org.springframework.data.annotation.Id; |
||||
import org.springframework.data.domain.PageRequest; |
||||
import org.springframework.data.mongodb.config.AbstractIntegrationTests; |
||||
import org.springframework.data.mongodb.core.IndexOperations; |
||||
import org.springframework.data.mongodb.core.MongoOperations; |
||||
import org.springframework.data.mongodb.core.index.IndexDefinition; |
||||
import org.springframework.data.mongodb.core.mapping.Document; |
||||
import org.springframework.data.mongodb.core.mapping.Field; |
||||
import org.springframework.data.mongodb.core.mapping.Language; |
||||
import org.springframework.data.mongodb.core.query.text.TextQueryTests.FullTextDoc.FullTextDocBuilder; |
||||
import org.springframework.data.mongodb.test.util.MongoVersionRule; |
||||
import org.springframework.data.util.Version; |
||||
|
||||
import com.mongodb.BasicDBObject; |
||||
import com.mongodb.DBObject; |
||||
|
||||
/** |
||||
* @author Christoph Strobl |
||||
*/ |
||||
public class TextQueryTests extends AbstractIntegrationTests { |
||||
|
||||
public static @ClassRule MongoVersionRule version = MongoVersionRule.atLeast(new Version(2, 6)); |
||||
|
||||
private static final FullTextDoc BAKE = new FullTextDocBuilder().headline("bake").build(); |
||||
private static final FullTextDoc COFFEE = new FullTextDocBuilder().subHeadline("coffee").build(); |
||||
private static final FullTextDoc CAKE = new FullTextDocBuilder().body("cake").build(); |
||||
private static final FullTextDoc NOT_TO_BE_FOUND = new FullTextDocBuilder().headline("o_O").build(); |
||||
private static final FullTextDoc SPANISH_MILK = new FullTextDocBuilder().headline("leche").lanugage("spanish") |
||||
.build(); |
||||
private static final FullTextDoc FRENCH_MILK = new FullTextDocBuilder().headline("leche").lanugage("french").build(); |
||||
private static final FullTextDoc MILK_AND_SUGAR = new FullTextDocBuilder().headline("milk and sugar").build(); |
||||
|
||||
private @Autowired MongoOperations template; |
||||
|
||||
@Before |
||||
public void setUp() { |
||||
|
||||
IndexOperations indexOps = template.indexOps(FullTextDoc.class); |
||||
indexOps.dropAllIndexes(); |
||||
|
||||
indexOps.ensureIndex(new IndexDefinition() { |
||||
|
||||
@Override |
||||
public DBObject getIndexOptions() { |
||||
DBObject options = new BasicDBObject(); |
||||
options.put("weights", weights()); |
||||
options.put("name", "TextQueryTests_TextIndex"); |
||||
options.put("language_override", "lang"); |
||||
options.put("default_language", "english"); |
||||
return options; |
||||
} |
||||
|
||||
@Override |
||||
public DBObject getIndexKeys() { |
||||
DBObject keys = new BasicDBObject(); |
||||
keys.put("headline", "text"); |
||||
keys.put("subheadline", "text"); |
||||
keys.put("body", "text"); |
||||
return keys; |
||||
} |
||||
|
||||
private DBObject weights() { |
||||
DBObject weights = new BasicDBObject(); |
||||
weights.put("headline", 10); |
||||
weights.put("subheadline", 5); |
||||
weights.put("body", 1); |
||||
return weights; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldOnlyFindDocumentsMatchingAnyWordOfGivenQuery() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
|
||||
List<FullTextDoc> result = template.find(new TextQuery("bake coffee cake"), FullTextDoc.class); |
||||
assertThat(result, hasSize(3)); |
||||
assertThat(result, hasItems(BAKE, COFFEE, CAKE)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldNotFindDocumentsWhenQueryDoesNotMatchAnyDocumentInIndex() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
|
||||
List<FullTextDoc> result = template.find(new TextQuery("tasmanian devil"), FullTextDoc.class); |
||||
assertThat(result, hasSize(0)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldApplySortByScoreCorrectly() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
FullTextDoc coffee2 = new FullTextDocBuilder().headline("coffee").build(); |
||||
template.insert(coffee2); |
||||
|
||||
List<FullTextDoc> result = template.find(new TextQuery("bake coffee cake").sortByScore(), FullTextDoc.class); |
||||
assertThat(result, hasSize(4)); |
||||
assertThat(result.get(0), anyOf(equalTo(BAKE), equalTo(coffee2))); |
||||
assertThat(result.get(1), anyOf(equalTo(BAKE), equalTo(coffee2))); |
||||
assertThat(result.get(2), equalTo(COFFEE)); |
||||
assertThat(result.get(3), equalTo(CAKE)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldFindTextInAnyLanguage() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
List<FullTextDoc> result = template.find(new TextQuery("leche"), FullTextDoc.class); |
||||
assertThat(result, hasSize(2)); |
||||
assertThat(result, hasItems(SPANISH_MILK, FRENCH_MILK)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldOnlyFindTextInSpecificLanguage() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
List<FullTextDoc> result = template.find(new TextQuery("leche").addCriteria(where("language").is("spanish")), |
||||
FullTextDoc.class); |
||||
assertThat(result, hasSize(1)); |
||||
assertThat(result.get(0), equalTo(SPANISH_MILK)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldNotFindDocumentsWithNegatedTerms() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
|
||||
List<FullTextDoc> result = template.find(new TextQuery("bake coffee -cake"), FullTextDoc.class); |
||||
assertThat(result, hasSize(2)); |
||||
assertThat(result, hasItems(BAKE, COFFEE)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldInlcudeScoreCorreclty() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
|
||||
List<FullTextDocWithScore> result = template.find(new TextQuery("bake coffee -cake").includeScore().sortByScore(), |
||||
FullTextDocWithScore.class); |
||||
|
||||
assertThat(result, hasSize(2)); |
||||
for (FullTextDocWithScore scoredDoc : result) { |
||||
assertTrue(scoredDoc.score > 0F); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldApplyPhraseCorrectly() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
|
||||
TextQuery query = TextQuery.queryText(TextCriteria.forDefaultLanguage().matchingPhrase("milk and sugar")); |
||||
List<FullTextDoc> result = template.find(query, FullTextDoc.class); |
||||
|
||||
assertThat(result, hasSize(1)); |
||||
assertThat(result, contains(MILK_AND_SUGAR)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldReturnEmptyListWhenNoDocumentsMatchGivenPhrase() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
|
||||
TextQuery query = TextQuery.queryText(TextCriteria.forDefaultLanguage().matchingPhrase("milk no sugar")); |
||||
List<FullTextDoc> result = template.find(query, FullTextDoc.class); |
||||
|
||||
assertThat(result, empty()); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldApplyPaginationCorrectly() { |
||||
|
||||
initWithDefaultDocuments(); |
||||
|
||||
// page 1
|
||||
List<FullTextDoc> result = template.find(new TextQuery("bake coffee cake").sortByScore() |
||||
.with(new PageRequest(0, 2)), FullTextDoc.class); |
||||
assertThat(result, hasSize(2)); |
||||
assertThat(result, contains(BAKE, COFFEE)); |
||||
|
||||
// page 2
|
||||
result = template.find(new TextQuery("bake coffee cake").sortByScore().with(new PageRequest(1, 2)), |
||||
FullTextDoc.class); |
||||
assertThat(result, hasSize(1)); |
||||
assertThat(result, contains(CAKE)); |
||||
} |
||||
|
||||
private void initWithDefaultDocuments() { |
||||
this.template.save(BAKE); |
||||
this.template.save(COFFEE); |
||||
this.template.save(CAKE); |
||||
this.template.save(NOT_TO_BE_FOUND); |
||||
this.template.save(SPANISH_MILK); |
||||
this.template.save(FRENCH_MILK); |
||||
this.template.save(MILK_AND_SUGAR); |
||||
} |
||||
|
||||
static class FullTextDocWithScore extends FullTextDoc { |
||||
|
||||
public Float score; |
||||
|
||||
} |
||||
|
||||
@Document(collection = "fullTextDoc") |
||||
static class FullTextDoc { |
||||
|
||||
@Id String id; |
||||
|
||||
private @Language @Field("lang") String language; |
||||
|
||||
private String headline; |
||||
private String subheadline; |
||||
private String body; |
||||
|
||||
@Override |
||||
public int hashCode() { |
||||
final int prime = 31; |
||||
int result = 1; |
||||
result = prime * result + ((body == null) ? 0 : body.hashCode()); |
||||
result = prime * result + ((headline == null) ? 0 : headline.hashCode()); |
||||
result = prime * result + ((id == null) ? 0 : id.hashCode()); |
||||
result = prime * result + ((language == null) ? 0 : language.hashCode()); |
||||
result = prime * result + ((subheadline == null) ? 0 : subheadline.hashCode()); |
||||
return result; |
||||
} |
||||
|
||||
@Override |
||||
public boolean equals(Object obj) { |
||||
if (this == obj) { |
||||
return true; |
||||
} |
||||
if (obj == null) { |
||||
return false; |
||||
} |
||||
if (!(obj instanceof FullTextDoc)) { |
||||
return false; |
||||
} |
||||
FullTextDoc other = (FullTextDoc) obj; |
||||
if (body == null) { |
||||
if (other.body != null) { |
||||
return false; |
||||
} |
||||
} else if (!body.equals(other.body)) { |
||||
return false; |
||||
} |
||||
if (headline == null) { |
||||
if (other.headline != null) { |
||||
return false; |
||||
} |
||||
} else if (!headline.equals(other.headline)) { |
||||
return false; |
||||
} |
||||
if (id == null) { |
||||
if (other.id != null) { |
||||
return false; |
||||
} |
||||
} else if (!id.equals(other.id)) { |
||||
return false; |
||||
} |
||||
if (language == null) { |
||||
if (other.language != null) { |
||||
return false; |
||||
} |
||||
} else if (!language.equals(other.language)) { |
||||
return false; |
||||
} |
||||
if (subheadline == null) { |
||||
if (other.subheadline != null) { |
||||
return false; |
||||
} |
||||
} else if (!subheadline.equals(other.subheadline)) { |
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
static class FullTextDocBuilder { |
||||
|
||||
private FullTextDoc instance; |
||||
|
||||
public FullTextDocBuilder() { |
||||
this.instance = new FullTextDoc(); |
||||
} |
||||
|
||||
public FullTextDocBuilder headline(String headline) { |
||||
this.instance.headline = headline; |
||||
return this; |
||||
} |
||||
|
||||
public FullTextDocBuilder subHeadline(String subHeadline) { |
||||
this.instance.subheadline = subHeadline; |
||||
return this; |
||||
} |
||||
|
||||
public FullTextDocBuilder body(String body) { |
||||
this.instance.body = body; |
||||
return this; |
||||
} |
||||
|
||||
public FullTextDocBuilder lanugage(String language) { |
||||
this.instance.language = language; |
||||
return this; |
||||
} |
||||
|
||||
public FullTextDoc build() { |
||||
return this.instance; |
||||
} |
||||
} |
||||
|
||||
} |
||||
} |
||||
@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
|
||||
/* |
||||
* Copyright 2014 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
package org.springframework.data.mongodb.core.query.text; |
||||
|
||||
import static org.junit.Assert.*; |
||||
import static org.springframework.data.mongodb.core.query.text.IsTextQuery.*; |
||||
|
||||
import org.junit.Test; |
||||
import org.springframework.data.domain.Sort; |
||||
import org.springframework.data.domain.Sort.Direction; |
||||
import org.springframework.data.mongodb.core.query.Query; |
||||
|
||||
/** |
||||
* @author Christoph Strobl |
||||
*/ |
||||
public class TextQueryUnitTests { |
||||
|
||||
private static final String QUERY = "bake coffee cake"; |
||||
private static final String LANGUAGE_SPANISH = "spanish"; |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldCreateQueryObjectCorrectly() { |
||||
assertThat(new TextQuery(QUERY), isTextQuery().searchingFor(QUERY)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldIncludeLanguageInQueryObjectWhenNotNull() { |
||||
assertThat(new TextQuery(QUERY, LANGUAGE_SPANISH), isTextQuery().searchingFor(QUERY).inLanguage(LANGUAGE_SPANISH)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldIncludeScoreFieldCorrectly() { |
||||
assertThat(new TextQuery(QUERY).includeScore(), isTextQuery().searchingFor(QUERY).returningScore()); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldNotOverrideExistingProjections() { |
||||
|
||||
TextQuery query = new TextQuery(TextCriteria.forDefaultLanguage().matching(QUERY)).includeScore(); |
||||
query.fields().include("foo"); |
||||
|
||||
assertThat(query, isTextQuery().searchingFor(QUERY).returningScore().includingField("foo")); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldIncludeSortingByScoreCorrectly() { |
||||
assertThat(new TextQuery(QUERY).sortByScore(), isTextQuery().searchingFor(QUERY).returningScore().sortingByScore()); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldNotOverrideExistingSort() { |
||||
|
||||
TextQuery query = new TextQuery(QUERY); |
||||
query.with(new Sort(Direction.DESC, "foo")); |
||||
query.sortByScore(); |
||||
|
||||
assertThat(query, |
||||
isTextQuery().searchingFor(QUERY).returningScore().sortingByScore().sortingBy("foo", Direction.DESC)); |
||||
} |
||||
|
||||
/** |
||||
* @see DATAMONGO-850 |
||||
*/ |
||||
@Test |
||||
public void shouldUseCustomFieldnameForScoring() { |
||||
TextQuery query = new TextQuery(QUERY).includeScore("customFieldForScore").sortByScore(); |
||||
|
||||
assertThat(query, isTextQuery().searchingFor(QUERY).returningScoreAs("customFieldForScore").sortingByScore()); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue