diff --git a/src/main/java/com/avaje/ebean/ExpressionFactory.java b/src/main/java/com/avaje/ebean/ExpressionFactory.java index 884bab4db..9c239d870 100644 --- a/src/main/java/com/avaje/ebean/ExpressionFactory.java +++ b/src/main/java/com/avaje/ebean/ExpressionFactory.java @@ -1,6 +1,7 @@ package com.avaje.ebean; import com.avaje.ebean.search.Match; +import com.avaje.ebean.search.MultiMatch; import java.util.Collection; import java.util.List; @@ -292,6 +293,11 @@ public interface ExpressionFactory { */ Expression textMatch(String propertyName, String search, Match options); + /** + * Create a Text Multi match expression (currently doc store/Elastic only). + */ + Expression textMultiMatch(String query, MultiMatch options); + /** * And - join two expressions with a logical and. */ @@ -334,4 +340,5 @@ public interface ExpressionFactory { *

*/ TextJunction textJunction(Query query, TextExpressionList parent, TextJunction.Type type); + } diff --git a/src/main/java/com/avaje/ebean/TextExpressionList.java b/src/main/java/com/avaje/ebean/TextExpressionList.java index a30fc7179..b10021e4f 100644 --- a/src/main/java/com/avaje/ebean/TextExpressionList.java +++ b/src/main/java/com/avaje/ebean/TextExpressionList.java @@ -1,6 +1,7 @@ package com.avaje.ebean; import com.avaje.ebean.search.Match; +import com.avaje.ebean.search.MultiMatch; /** * An list of Full text query expressions. @@ -26,6 +27,16 @@ public interface TextExpressionList extends ExpressionList { */ TextExpressionList match(String propertyName, String search, Match options); + /** + * Add a multi-match expression. + */ + TextExpressionList multiMatch(String search, String... properties); + + /** + * Add a multi-match expression using options. + */ + TextExpressionList multiMatch(String search, MultiMatch options); + /** * Start a list of expressions that will be joined by MUST. */ @@ -55,4 +66,5 @@ public interface TextExpressionList extends ExpressionList { * End the list of MUST NOT expressions. */ TextExpressionList endMustNot(); + } diff --git a/src/main/java/com/avaje/ebean/search/BaseMatch.java b/src/main/java/com/avaje/ebean/search/BaseMatch.java new file mode 100644 index 000000000..90daf51d2 --- /dev/null +++ b/src/main/java/com/avaje/ebean/search/BaseMatch.java @@ -0,0 +1,130 @@ +package com.avaje.ebean.search; + +/** + * Options for the text match expression. + */ +public abstract class BaseMatch { + + protected boolean and; + + protected String analyzer; + + protected double boost; + + protected String minShouldMatch; + + protected int maxExpansions; + + protected String zeroTerms; + + protected double cutoffFrequency; + + protected String fuzziness; + + protected int prefixLength; + + protected String rewrite; + + /** + * Return true if using the AND operator otherwise using the OR operator. + */ + public boolean isAnd() { + return and; + } + + /** + * Return the boost. + */ + public double getBoost() { + return boost; + } + + /** + * Return the minimum should match. + */ + public String getMinShouldMatch() { + return minShouldMatch; + } + + /** + * Return the zero terms option. + */ + public String getZeroTerms() { + return zeroTerms; + } + + /** + * Return the cutoff frequency. + */ + public double getCutoffFrequency() { + return cutoffFrequency; + } + + /** + * Return the max expansions. + */ + public int getMaxExpansions() { + return maxExpansions; + } + + /** + * Return the analyzer. + */ + public String getAnalyzer() { + return analyzer; + } + + /** + * Return the fuzziness. + */ + public String getFuzziness() { + return fuzziness; + } + + /** + * Return the prefix length. + */ + public int getPrefixLength() { + return prefixLength; + } + + /** + * Return the rewrite option. + */ + public String getRewrite() { + return rewrite; + } + + protected boolean baseEquals(BaseMatch baseMatch) { + + if (and != baseMatch.and) return false; + if (Double.compare(baseMatch.boost, boost) != 0) return false; + if (maxExpansions != baseMatch.maxExpansions) return false; + if (Double.compare(baseMatch.cutoffFrequency, cutoffFrequency) != 0) return false; + if (prefixLength != baseMatch.prefixLength) return false; + if (analyzer != null ? !analyzer.equals(baseMatch.analyzer) : baseMatch.analyzer != null) return false; + if (minShouldMatch != null ? !minShouldMatch.equals(baseMatch.minShouldMatch) : baseMatch.minShouldMatch != null) return false; + if (zeroTerms != null ? !zeroTerms.equals(baseMatch.zeroTerms) : baseMatch.zeroTerms != null) return false; + if (fuzziness != null ? !fuzziness.equals(baseMatch.fuzziness) : baseMatch.fuzziness != null) return false; + return rewrite != null ? rewrite.equals(baseMatch.rewrite) : baseMatch.rewrite == null; + } + + protected int baseHashCode() { + + int result; + long temp; + result = (and ? 1 : 0); + result = 31 * result + (analyzer != null ? analyzer.hashCode() : 0); + temp = Double.doubleToLongBits(boost); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + result = 31 * result + (minShouldMatch != null ? minShouldMatch.hashCode() : 0); + result = 31 * result + maxExpansions; + result = 31 * result + (zeroTerms != null ? zeroTerms.hashCode() : 0); + temp = Double.doubleToLongBits(cutoffFrequency); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + result = 31 * result + (fuzziness != null ? fuzziness.hashCode() : 0); + result = 31 * result + prefixLength; + result = 31 * result + (rewrite != null ? rewrite.hashCode() : 0); + return result; + } +} diff --git a/src/main/java/com/avaje/ebean/search/Match.java b/src/main/java/com/avaje/ebean/search/Match.java index 4de641ea6..391d7c184 100644 --- a/src/main/java/com/avaje/ebean/search/Match.java +++ b/src/main/java/com/avaje/ebean/search/Match.java @@ -3,26 +3,12 @@ package com.avaje.ebean.search; /** * Options for the text match expression. */ -public class Match { - - protected boolean and; - - protected double boost; - - protected String minShouldMatch; - - protected String zeroTerms; - - protected double cutoffFrequency; - - protected String analyzer; +public class Match extends BaseMatch { protected boolean phrase; protected boolean phrasePrefix; - protected int maxExpansions; - /** * Create and return Match options using AND operator. */ @@ -37,38 +23,6 @@ public class Match { return new Match().opOr(); } - /** - * Use the AND operator (rather than OR). - */ - public Match opAnd() { - and = true; - return this; - } - - /** - * Use the OR operator (rather than AND). - */ - public Match opOr() { - and = false; - return this; - } - - /** - * Set the minimum should match value. - */ - public Match minShouldMatch(String minShouldMatch) { - this.minShouldMatch = minShouldMatch; - return this; - } - - /** - * Set the boost. - */ - public Match boost(double boost) { - this.boost = boost; - return this; - } - /** * Set this to be a "Phrase" type expression. */ @@ -85,6 +39,22 @@ public class Match { return this; } + /** + * Use the AND operator (rather than OR). + */ + public Match opAnd() { + and = true; + return this; + } + + /** + * Use the OR operator (rather than AND). + */ + public Match opOr() { + and = false; + return this; + } + /** * Set the zero terms. */ @@ -118,50 +88,43 @@ public class Match { } /** - * Return true if using the AND operator otherwise using the OR operator. + * Set the boost. */ - public boolean isAnd() { - return and; + public Match boost(double boost) { + this.boost = boost; + return this; } /** - * Return the boost. + * Set the rewrite to use. */ - public double getBoost() { - return boost; + public Match minShouldMatch(String minShouldMatch) { + this.minShouldMatch = minShouldMatch; + return this; } /** - * Return the minimum should match. + * Set the rewrite to use. */ - public String getMinShouldMatch() { - return minShouldMatch; - } - - public String getZeroTerms() { - return zeroTerms; - } - - public double getCutoffFrequency() { - return cutoffFrequency; + public Match rewrite(String rewrite) { + this.rewrite = rewrite; + return this; } + /** + * Return true if this is a phrase query. + */ public boolean isPhrase() { return phrase; } + /** + * Return true if this is a phrase prefix query. + */ public boolean isPhrasePrefix() { return phrasePrefix; } - public int getMaxExpansions() { - return maxExpansions; - } - - public String getAnalyzer() { - return analyzer; - } - @Override public boolean equals(Object o) { if (this == o) return true; @@ -169,33 +132,16 @@ public class Match { Match match = (Match) o; - if (and != match.and) return false; - if (Double.compare(match.boost, boost) != 0) return false; - if (Double.compare(match.cutoffFrequency, cutoffFrequency) != 0) return false; if (phrase != match.phrase) return false; if (phrasePrefix != match.phrasePrefix) return false; - if (maxExpansions != match.maxExpansions) return false; - if (minShouldMatch != null ? !minShouldMatch.equals(match.minShouldMatch) : match.minShouldMatch != null) - return false; - if (zeroTerms != null ? !zeroTerms.equals(match.zeroTerms) : match.zeroTerms != null) return false; - return analyzer != null ? analyzer.equals(match.analyzer) : match.analyzer == null; + return baseEquals(match); } @Override public int hashCode() { - int result; - long temp; - result = (and ? 1 : 0); - temp = Double.doubleToLongBits(boost); - result = 31 * result + (int) (temp ^ (temp >>> 32)); - result = 31 * result + (minShouldMatch != null ? minShouldMatch.hashCode() : 0); - result = 31 * result + (zeroTerms != null ? zeroTerms.hashCode() : 0); - temp = Double.doubleToLongBits(cutoffFrequency); - result = 31 * result + (int) (temp ^ (temp >>> 32)); - result = 31 * result + (analyzer != null ? analyzer.hashCode() : 0); - result = 31 * result + (phrase ? 1 : 0); + int result = (phrase ? 1 : 0); result = 31 * result + (phrasePrefix ? 1 : 0); - result = 31 * result + maxExpansions; + result = 31 * result + baseHashCode(); return result; } } diff --git a/src/main/java/com/avaje/ebean/search/MultiMatch.java b/src/main/java/com/avaje/ebean/search/MultiMatch.java new file mode 100644 index 000000000..175b6469c --- /dev/null +++ b/src/main/java/com/avaje/ebean/search/MultiMatch.java @@ -0,0 +1,186 @@ +package com.avaje.ebean.search; + +import java.util.Arrays; + +/** + * Options for the text match expression. + */ +public class MultiMatch extends BaseMatch { + + /** + * The MultiMatch type. + */ + public enum Type { + BEST_FIELDS, + MOST_FIELDS, + CROSS_FIELDS, + PHRASE, + PHRASE_PREFIX + } + + protected Type type = Type.BEST_FIELDS; + + protected String[] fields; + + protected double tieBreaker; + + /** + * Create and return Match options using AND operator. + */ + public static MultiMatch fields(String... fields) { + return new MultiMatch(fields); + } + + /** + * Create and return Match options using OR operator. + */ + public static MultiMatch OR() { + return new MultiMatch().opOr(); + } + + /** + * Construct with a set of fields. + */ + public MultiMatch(String... fields) { + this.fields = fields; + } + + /** + * Set the type of query. + */ + public MultiMatch type(Type type) { + this.type = type; + return this; + } + + /** + * Set the tieBreaker to use. + */ + public MultiMatch tieBreaker(double tieBreaker) { + this.tieBreaker = tieBreaker; + return this; + } + + /** + * Use the AND operator (rather than OR). + */ + public MultiMatch opAnd() { + and = true; + return this; + } + + /** + * Use the OR operator (rather than AND). + */ + public MultiMatch opOr() { + and = false; + return this; + } + + /** + * Set the minimum should match value. + */ + public MultiMatch minShouldMatch(String minShouldMatch) { + this.minShouldMatch = minShouldMatch; + return this; + } + + /** + * Set the boost. + */ + public MultiMatch boost(double boost) { + this.boost = boost; + return this; + } + + /** + * Set the zero terms. + */ + public MultiMatch zeroTerms(String zeroTerms) { + this.zeroTerms = zeroTerms; + return this; + } + + /** + * Set the cutoff frequency. + */ + public MultiMatch cutoffFrequency(double cutoffFrequency) { + this.cutoffFrequency = cutoffFrequency; + return this; + } + + /** + * Set the max expansions (for phrase prefix only). + */ + public MultiMatch maxExpansions(int maxExpansions) { + this.maxExpansions = maxExpansions; + return this; + } + + /** + * Set the Analyzer to use for this expression. + */ + public MultiMatch analyzer(String analyzer) { + this.analyzer = analyzer; + return this; + } + + /** + * Set the rewrite to use. + */ + public MultiMatch rewrite(String rewrite) { + this.rewrite = rewrite; + return this; + } + + /** + * Return the type. + */ + public Type getType() { + return type; + } + + /** + * Return the fields to search. + */ + public String[] getFields() { + return fields; + } + + /** + * Return the tie breaker. + */ + public double getTieBreaker() { + return tieBreaker; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + MultiMatch that = (MultiMatch) o; + + if (Double.compare(that.tieBreaker, tieBreaker) != 0) return false; + if (type != that.type) return false; + if (fields.length != that.fields.length) return false; + for (int i = 0; i < fields.length; i++) { + if (!fields[i].equals(that.fields[i])) { + return false; + } + } + return baseEquals(that); + } + + @Override + public int hashCode() { + int result; + long temp; + result = type.hashCode(); + result = 31 * result + Arrays.hashCode(fields); + temp = Double.doubleToLongBits(tieBreaker); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + result = 31 * result + baseHashCode(); + return result; + } +} diff --git a/src/main/java/com/avaje/ebeaninternal/server/expression/DTextJunction.java b/src/main/java/com/avaje/ebeaninternal/server/expression/DTextJunction.java index baba32c88..50a66c8f7 100644 --- a/src/main/java/com/avaje/ebeaninternal/server/expression/DTextJunction.java +++ b/src/main/java/com/avaje/ebeaninternal/server/expression/DTextJunction.java @@ -4,6 +4,7 @@ import com.avaje.ebean.Query; import com.avaje.ebean.TextExpressionList; import com.avaje.ebean.TextJunction; import com.avaje.ebean.search.Match; +import com.avaje.ebean.search.MultiMatch; import com.avaje.ebeaninternal.api.SpiExpression; import com.avaje.ebeaninternal.api.SpiTextJunction; @@ -47,6 +48,16 @@ class DTextJunction extends JunctionExpression implements SpiTextJunction< return exprList.match(propertyName, search, options); } + @Override + public TextExpressionList multiMatch(String query, String... properties) { + return exprList.multiMatch(query, properties); + } + + @Override + public TextExpressionList multiMatch(String query, MultiMatch options) { + return exprList.multiMatch(query, options); + } + @Override public TextJunction must() { return exprList.must(); diff --git a/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionFactory.java b/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionFactory.java index 4a9bcd512..841f259ff 100644 --- a/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionFactory.java +++ b/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionFactory.java @@ -11,6 +11,7 @@ import com.avaje.ebean.TextExpressionList; import com.avaje.ebean.TextJunction; import com.avaje.ebean.bean.EntityBean; import com.avaje.ebean.search.Match; +import com.avaje.ebean.search.MultiMatch; import com.avaje.ebeaninternal.api.SpiExpressionFactory; import com.avaje.ebeaninternal.api.SpiQuery; @@ -44,6 +45,11 @@ public class DefaultExpressionFactory implements SpiExpressionFactory { return new TextMatchExpression(propertyName, search, options); } + @Override + public Expression textMultiMatch(String query, MultiMatch options) { + return new TextMultiMatchExpression(query, options); + } + public Expression jsonExists(String propertyName, String path) { return new JsonPathExpression(propertyName, path, Op.EXISTS, null); } diff --git a/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionList.java b/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionList.java index fae3ae3e5..7f8fc4242 100644 --- a/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionList.java +++ b/src/main/java/com/avaje/ebeaninternal/server/expression/DefaultExpressionList.java @@ -3,6 +3,7 @@ package com.avaje.ebeaninternal.server.expression; import com.avaje.ebean.*; import com.avaje.ebean.event.BeanQueryRequest; import com.avaje.ebean.search.Match; +import com.avaje.ebean.search.MultiMatch; import com.avaje.ebeaninternal.api.HashQueryPlanBuilder; import com.avaje.ebeaninternal.api.ManyWhereJoins; import com.avaje.ebeaninternal.api.SpiExpression; @@ -864,6 +865,17 @@ public class DefaultExpressionList implements SpiExpressionList, SpiTextEx return this; } + @Override + public TextExpressionList multiMatch(String query, String... fields) { + return multiMatch(query, new MultiMatch().fields(fields)); + } + + @Override + public TextExpressionList multiMatch(String query, MultiMatch options) { + add(expr.textMultiMatch(query, options)); + return this; + } + @Override public TextJunction must() { return textJunction(TextJunction.Type.MUST); diff --git a/src/main/java/com/avaje/ebeaninternal/server/expression/DocQueryContext.java b/src/main/java/com/avaje/ebeaninternal/server/expression/DocQueryContext.java index 6943fa0c6..8a66d3bf1 100644 --- a/src/main/java/com/avaje/ebeaninternal/server/expression/DocQueryContext.java +++ b/src/main/java/com/avaje/ebeaninternal/server/expression/DocQueryContext.java @@ -3,6 +3,7 @@ package com.avaje.ebeaninternal.server.expression; import com.avaje.ebean.LikeType; import com.avaje.ebean.TextJunction; import com.avaje.ebean.search.Match; +import com.avaje.ebean.search.MultiMatch; import java.io.IOException; import java.util.List; @@ -98,6 +99,11 @@ public interface DocQueryContext { */ void writeMatch(String propName, String search, Match options) throws IOException; + /** + * Write a Multi-match expression. + */ + void writeMultiMatch(String search, MultiMatch options) throws IOException; + /** * Start a Bool which may contain some of Must, Must Not, Should. */ @@ -117,4 +123,5 @@ public interface DocQueryContext { * End the Bool group. */ void endBoolGroup() throws IOException; + } diff --git a/src/main/java/com/avaje/ebeaninternal/server/expression/TextMatchExpression.java b/src/main/java/com/avaje/ebeaninternal/server/expression/TextMatchExpression.java index 47dc9e6f2..850382727 100644 --- a/src/main/java/com/avaje/ebeaninternal/server/expression/TextMatchExpression.java +++ b/src/main/java/com/avaje/ebeaninternal/server/expression/TextMatchExpression.java @@ -1,5 +1,6 @@ package com.avaje.ebeaninternal.server.expression; +import com.avaje.ebean.search.BaseMatch; import com.avaje.ebean.search.Match; import com.avaje.ebeaninternal.api.HashQueryPlanBuilder; import com.avaje.ebeaninternal.api.SpiExpression; @@ -44,9 +45,9 @@ public class TextMatchExpression extends AbstractExpression { public void queryPlanHash(HashQueryPlanBuilder builder) { builder.add(TextMatchExpression.class).add(propName).add(search); if (options != null) { - builder.add(options.isAnd()); - builder.add(options.getBoost()); - builder.add(options.getMinShouldMatch()); + builder.add(options.isPhrase()); + builder.add(options.isPhrasePrefix()); + addHash(builder, options); } } @@ -72,4 +73,20 @@ public class TextMatchExpression extends AbstractExpression { TextMatchExpression that = (TextMatchExpression) other; return search.equals(that.search); } + + /** + * Add the hash to the builder for the base/common options. + */ + public static void addHash(HashQueryPlanBuilder builder, BaseMatch options) { + builder.add(options.isAnd()); + builder.add(options.getAnalyzer()); + builder.add(options.getBoost()); + builder.add(options.getCutoffFrequency()); + builder.add(options.getFuzziness()); + builder.add(options.getMaxExpansions()); + builder.add(options.getMinShouldMatch()); + builder.add(options.getPrefixLength()); + builder.add(options.getRewrite()); + builder.add(options.getZeroTerms()); + } } diff --git a/src/main/java/com/avaje/ebeaninternal/server/expression/TextMultiMatchExpression.java b/src/main/java/com/avaje/ebeaninternal/server/expression/TextMultiMatchExpression.java new file mode 100644 index 000000000..7eee078e2 --- /dev/null +++ b/src/main/java/com/avaje/ebeaninternal/server/expression/TextMultiMatchExpression.java @@ -0,0 +1,77 @@ +package com.avaje.ebeaninternal.server.expression; + +import com.avaje.ebean.search.MultiMatch; +import com.avaje.ebeaninternal.api.HashQueryPlanBuilder; +import com.avaje.ebeaninternal.api.SpiExpression; +import com.avaje.ebeaninternal.api.SpiExpressionRequest; + +import java.io.IOException; + +/** + * Full text Multi-Match expression. + */ +public class TextMultiMatchExpression extends AbstractExpression { + + private final String search; + + private final MultiMatch options; + + public TextMultiMatchExpression(String search, MultiMatch options) { + super(null); + this.search = search; + this.options = options; + } + + @Override + public void writeDocQuery(DocQueryContext context) throws IOException { + context.writeMultiMatch(search, options); + } + + @Override + public void addSql(SpiExpressionRequest request) { + throw new IllegalStateException("Not implemented - DocStore/Elastic only"); + } + + @Override + public void addBindValues(SpiExpressionRequest request) { + throw new IllegalStateException("Not implemented - DocStore/Elastic only"); + } + + /** + * Based on the type and propertyName. + */ + @Override + public void queryPlanHash(HashQueryPlanBuilder builder) { + builder.add(TextMultiMatchExpression.class).add(search); + builder.add(options.getType()); + builder.add(options.getTieBreaker()); + String[] fields = options.getFields(); + builder.add(fields.length); + for (int i = 0; i < fields.length; i++) { + builder.add(fields[i]); + } + TextMatchExpression.addHash(builder, options); + } + + @Override + public int queryBindHash() { + return search.hashCode(); + } + + @Override + public boolean isSameByPlan(SpiExpression other) { + if (!(other instanceof TextMultiMatchExpression)) { + return false; + } + + TextMultiMatchExpression that = (TextMultiMatchExpression) other; + return this.search.equals(that.search) + && this.options == null ? that.options == null : options.equals(that.options); + } + + @Override + public boolean isSameByBind(SpiExpression other) { + TextMultiMatchExpression that = (TextMultiMatchExpression) other; + return search.equals(that.search); + } +} diff --git a/src/test/java/com/avaje/ebean/TextExpressionListTest.java b/src/test/java/com/avaje/ebean/TextExpressionListTest.java index 41a9770e1..c6b136323 100644 --- a/src/test/java/com/avaje/ebean/TextExpressionListTest.java +++ b/src/test/java/com/avaje/ebean/TextExpressionListTest.java @@ -2,6 +2,7 @@ package com.avaje.ebean; import com.avaje.ebean.search.Match; +import com.avaje.ebean.search.MultiMatch; import com.avaje.tests.model.basic.Order; import org.junit.Test; @@ -59,4 +60,21 @@ public class TextExpressionListTest { } + + @Test + public void syntax_multiMatch() { + + Ebean.find(Order.class) + .text() + .multiMatch("Will Smith", "title", "*name"); + + MultiMatch match = MultiMatch.fields("title", "*name") + .opAnd() + .type(MultiMatch.Type.PHRASE_PREFIX); + + Ebean.find(Order.class) + .text() + .multiMatch("Will Smith", match); + + } } \ No newline at end of file