diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/BasicQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/BasicQuery.java index 396a236a0..d169864c4 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/BasicQuery.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/BasicQuery.java @@ -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; * * @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 { public void setSortObject(DBObject sortObject) { this.sortObject = sortObject; } + + /** + * @since 1.6 + * @param fieldsObject + */ + protected void setFieldsObject(DBObject fieldsObject) { + this.fieldsObject = fieldsObject; + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/Term.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/Term.java new file mode 100644 index 000000000..1b1db21cf --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/Term.java @@ -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; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextCriteria.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextCriteria.java new file mode 100644 index 000000000..ba1cdb805 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextCriteria.java @@ -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 terms; + + public TextCriteria() { + this.terms = new ArrayList(); + } + + /* + * (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 Text Search Languages. + * + * @param language + * @return + */ + public static TextCriteria forLanguage(String language) { + return new TextCriteriaBuilder().withLanguage(language).build(); + } + + private static String join(Iterator 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"; + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextQuery.java new file mode 100644 index 000000000..e56776e79 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextQuery.java @@ -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}.
+ * For a full list of supported languages see the mongdodb reference manual for Text Search Languages. + * + * @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.
+ * For a full list of supported languages see the mongdodb reference manual for Text Search Languages. + * + * @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; + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/IsQuery.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/IsQuery.java new file mode 100644 index 000000000..bcf379c70 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/IsQuery.java @@ -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 + */ +public class IsQuery extends TypeSafeMatcher { + + 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 IsQuery isQuery() { + return new IsQuery(); + } + + public IsQuery limitingTo(int limit) { + this.limit = limit; + return this; + } + + public IsQuery skippig(int skip) { + this.skip = skip; + return this; + } + + public IsQuery providingHint(String hint) { + this.hint = hint; + return this; + } + + public IsQuery includingField(String fieldname) { + + if (fields == null) { + fields = new BasicDBObject(); + } + fields.put(fieldname, 1); + + return this; + } + + public IsQuery excludingField(String fieldname) { + + if (fields == null) { + fields = new BasicDBObject(); + } + fields.put(fieldname, -1); + + return this; + } + + public IsQuery 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(query).matches(item.getQueryObject())) { + return false; + } + + if (!new IsEqual(sort).matches(item.getSortObject())) { + return false; + } + + if (!new IsEqual(fields).matches(item.getFieldsObject())) { + return false; + } + + if (!new IsEqual(this.hint).matches(item.getHint())) { + return false; + } + + if (!new IsEqual(this.skip).matches(item.getSkip())) { + return false; + } + + if (!new IsEqual(this.limit).matches(item.getLimit())) { + return false; + } + + return true; + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/IsTextQuery.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/IsTextQuery.java new file mode 100644 index 000000000..94b3e1928 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/IsTextQuery.java @@ -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 + */ +public class IsTextQuery extends IsQuery { + + 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 IsTextQuery isTextQuery() { + return new IsTextQuery(); + } + + public IsTextQuery searchingFor(String term) { + appendTerm(term); + return this; + } + + public IsTextQuery inLanguage(String language) { + appendLanguage(language); + return this; + } + + public IsTextQuery returningScore() { + + if (fields == null) { + fields = new BasicDBObject(); + } + fields.put(scoreFieldName, META_TEXT_SCORE); + + return this; + } + + public IsTextQuery returningScoreAs(String fieldname) { + + this.scoreFieldName = fieldname != null ? fieldname : SCORE_DEFAULT_FIELDNAME; + + return this.returningScore(); + } + + public IsTextQuery 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); + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextCriteriaUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextCriteriaUnitTests.java new file mode 100644 index 000000000..47c17e166 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextCriteriaUnitTests.java @@ -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. 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. equalTo(new BasicDBObject("$search", "-\"coffee cake\""))); + } + + private DBObject searchObject(String json) { + return new BasicDBObject("$text", JSON.parse(json)); + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryTests.java new file mode 100644 index 000000000..db7fe8fda --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryTests.java @@ -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 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 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 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 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 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 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 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 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 result = template.find(query, FullTextDoc.class); + + assertThat(result, empty()); + } + + /** + * @see DATAMONGO-850 + */ + @Test + public void shouldApplyPaginationCorrectly() { + + initWithDefaultDocuments(); + + // page 1 + List 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; + } + } + + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryUnitTests.java new file mode 100644 index 000000000..0ad4d45b3 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryUnitTests.java @@ -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()); + } + +} diff --git a/src/docbkx/reference/mongodb.xml b/src/docbkx/reference/mongodb.xml index d85b28ef0..66301bb8d 100644 --- a/src/docbkx/reference/mongodb.xml +++ b/src/docbkx/reference/mongodb.xml @@ -2016,6 +2016,67 @@ GeoResults<Restaurant> = operations.geoNear(query, Restaurant.class); + +
+ Full Text Queries + + Since mongodb 2.6 full text queries can be excuted using the + $text operator. Methods and operations specific for + full text queries are available in TextQuery and + TextCriteria. When doing full text search please + refer to the mongodb + reference for its behavior and limitations. + + + Full Text Search + + 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 + Text Index for + creating index structures. + + db.foo.ensureIndex( +{ + title : "text", + content : "text" +}, +{ + weights : { + title : 3 + } +} +) + + + A query searching for coffee cake, sorted by + relevance according to the weights can be defined + and executed as: + + Query query = TextQuery.searching(new TextCriteria().matchingAny("coffee", "cake")).sortByScore(); +List<Document> page = template.find(query, Document.class); + + + Exclusion of search terms can directly be done by prefixing the + term with - or using + notMatching + + // search for 'coffee' and not 'cake' +TextQuery.searching(new TextCriteria().matching("coffee").matching("-cake")); +TextQuery.searching(new TextCriteria().matching("coffee").notMatching("cake")); + + + As TextCriteria.matching takes the + provided term as is. Therefore phrases can be defined by putting them + between double quotes (eg. \"coffee cake\") or + using TextCriteria.phrase. + + // search for phrase 'coffee cake' +TextQuery.searching(new TextCriteria().matching("\"coffee cake\"")); +TextQuery.searching(new TextCriteria().phrase("coffee cake")); + + +