From 83ffbb00e860197fe2464c44eea1d8a09bf71a0c Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 26 Jun 2014 14:11:37 +0200 Subject: [PATCH] DATAMONGO-850 - Add support for full text search via $text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../data/mongodb/core/query/BasicQuery.java | 13 +- .../data/mongodb/core/query/text/Term.java | 102 +++++ .../mongodb/core/query/text/TextCriteria.java | 223 +++++++++++ .../mongodb/core/query/text/TextQuery.java | 178 +++++++++ .../data/mongodb/core/query/IsQuery.java | 143 +++++++ .../mongodb/core/query/text/IsTextQuery.java | 109 +++++ .../query/text/TextCriteriaUnitTests.java | 120 ++++++ .../core/query/text/TextQueryTests.java | 375 ++++++++++++++++++ .../core/query/text/TextQueryUnitTests.java | 102 +++++ src/docbkx/reference/mongodb.xml | 61 +++ 10 files changed, 1424 insertions(+), 2 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/Term.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextCriteria.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/text/TextQuery.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/IsQuery.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/IsTextQuery.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextCriteriaUnitTests.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryTests.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/query/text/TextQueryUnitTests.java 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")); + + +