Browse Source

DATAMONGO-850 - Add support for full text search via $text

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
Christoph Strobl 12 years ago committed by Thomas Darimont
parent
commit
83ffbb00e8
  1. 13
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/BasicQuery.java
  2. 102
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/Term.java
  3. 223
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextCriteria.java
  4. 178
      spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextQuery.java
  5. 143
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/IsQuery.java
  6. 109
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/IsTextQuery.java
  7. 120
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextCriteriaUnitTests.java
  8. 375
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryTests.java
  9. 102
      spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryUnitTests.java
  10. 61
      src/docbkx/reference/mongodb.xml

13
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/BasicQuery.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2010-2011 the original author or authors.
* Copyright 2010-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.
@ -24,11 +24,12 @@ import com.mongodb.util.JSON; @@ -24,11 +24,12 @@ import com.mongodb.util.JSON;
*
* @author Thomas Risberg
* @author Oliver Gierke
* @author Christoph Strobl
*/
public class BasicQuery extends Query {
private final DBObject queryObject;
private final DBObject fieldsObject;
private DBObject fieldsObject;
private DBObject sortObject;
public BasicQuery(String query) {
@ -84,4 +85,12 @@ public class BasicQuery extends Query { @@ -84,4 +85,12 @@ public class BasicQuery extends Query {
public void setSortObject(DBObject sortObject) {
this.sortObject = sortObject;
}
/**
* @since 1.6
* @param fieldsObject
*/
protected void setFieldsObject(DBObject fieldsObject) {
this.fieldsObject = fieldsObject;
}
}

102
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/Term.java

@ -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;
}
}

223
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextCriteria.java

@ -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";
}
}

178
spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextQuery.java

@ -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;
}
}

143
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/IsQuery.java

@ -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;
}
}

109
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/IsTextQuery.java

@ -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);
}
}

120
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextCriteriaUnitTests.java

@ -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));
}
}

375
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryTests.java

@ -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;
}
}
}
}

102
spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryUnitTests.java

@ -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());
}
}

61
src/docbkx/reference/mongodb.xml

@ -2016,6 +2016,67 @@ GeoResults&lt;Restaurant&gt; = operations.geoNear(query, Restaurant.class);</pro @@ -2016,6 +2016,67 @@ GeoResults&lt;Restaurant&gt; = operations.geoNear(query, Restaurant.class);</pro
origin.</para>
</section>
</section>
<section id="mongo.textsearch">
<title>Full Text Queries</title>
<para>Since mongodb 2.6 full text queries can be excuted using the
<literal>$text</literal> operator. Methods and operations specific for
full text queries are available in <classname>TextQuery</classname> and
<classname>TextCriteria</classname>. When doing full text search please
refer to the <ulink
url="http://docs.mongodb.org/manual/reference/operator/query/text/#behavior">mongodb
reference</ulink> for its behavior and limitations.</para>
<example>
<title>Full Text Search</title>
<para>Bevore we are actually able to use full text search we have to
ensure to set up the search index correctly. Please refer to section
<link linkend="mapping-usage-indexes.text-index">Text Index</link> for
creating index structures.</para>
<programlisting language="javascript">db.foo.ensureIndex(
{
title : "text",
content : "text"
},
{
weights : {
title : 3
}
}
)
</programlisting>
<para>A query searching for <literal>coffee cake</literal>, sorted by
relevance according to the <literal>weights</literal> can be defined
and executed as:</para>
<programlisting language="java">Query query = TextQuery.searching(new TextCriteria().matchingAny("coffee", "cake")).sortByScore();
List&lt;Document&gt; page = template.find(query, Document.class);
</programlisting>
<para>Exclusion of search terms can directly be done by prefixing the
term with <literal>-</literal> or using
<literal>notMatching</literal></para>
<programlisting language="java">// search for 'coffee' and not 'cake'
TextQuery.searching(new TextCriteria().matching("coffee").matching("-cake"));
TextQuery.searching(new TextCriteria().matching("coffee").notMatching("cake"));
</programlisting>
<para>As <classname>TextCriteria.matching</classname> takes the
provided term as is. Therefore phrases can be defined by putting them
between double quotes (eg. <literal>\"coffee cake\")</literal> or
using <classname>TextCriteria.phrase.</classname></para>
<programlisting language="java">// search for phrase 'coffee cake'
TextQuery.searching(new TextCriteria().matching("\"coffee cake\""));
TextQuery.searching(new TextCriteria().phrase("coffee cake"));
</programlisting>
</example>
</section>
</section>
<section id="mongo.mapreduce">

Loading…
Cancel
Save