/* * Copyright 2012 - 2017 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.solr.core.query; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.geo.Box; import org.springframework.data.geo.Circle; import org.springframework.data.geo.Distance; import org.springframework.data.geo.Point; import org.springframework.util.Assert; /** * Criteria is the central class when constructing queries. It follows more or less a fluent API style, which allows to * easily chain together multiple criteria. * * @author Christoph Strobl * @author Philipp Jardas * @author Francisco Spaeth */ public class Criteria extends Node { public static final String WILDCARD = "*"; public static final String CRITERIA_VALUE_SEPERATOR = " "; private Field field; private float boost = Float.NaN; private Set<Predicate> predicates = new LinkedHashSet<>(); public Criteria() {} /** * @param function * @since 1.1 */ public Criteria(Function function) { Assert.notNull(function, "Cannot create Critiera for 'null' function."); function(function); } /** * Creates a new Criteria for the Filed with provided name * * @param fieldname */ public Criteria(String fieldname) { this(new SimpleField(fieldname)); } /** * Creates a new Criteria for the given field * * @param field */ public Criteria(Field field) { Assert.notNull(field, "Field for criteria must not be null"); Assert.hasText(field.getName(), "Field.name for criteria must not be null/empty"); this.field = field; } /** * Static factory method to create a new Criteria for field with given name * * @param fieldname must not be null * @return */ public static Criteria where(String fieldname) { return where(new SimpleField(fieldname)); } /** * Static factory method to create a new Criteria for function * * @param function must not be null * @return * @since 1.1 */ public static Criteria where(Function function) { return new Criteria(function); } /** * Static factory method to create a new Criteria for provided field * * @param field must not be null * @return */ public static Criteria where(Field field) { return new Criteria(field); } /** * Crates new {@link Predicate} without any wildcards. Strings with blanks will be escaped * {@code "string\ with\ blank"} * * @param o * @return */ public Criteria is(Object o) { if (o == null) { return isNull(); } predicates.add(new Predicate(OperationKey.EQUALS, o)); return this; } /** * Crates new {@link Predicate} without any wildcards for each entry * * @param values * @return */ public Criteria is(Object... values) { return in(values); } /** * Creates new {@link Predicate} without any wildcards for each entry * * @param values * @return */ public Criteria is(Iterable<?> values) { return in(values); } /** * Crates new {@link Predicate} for {@code null} values * * @return */ public Criteria isNull() { return between(null, null).not(); } /** * Crates new {@link Predicate} for {@code !null} values * * @return */ public Criteria isNotNull() { return between(null, null); } /** * Crates new {@link Predicate} with leading and trailing wildcards <br/> * <strong>NOTE: </strong>mind your schema as leading wildcards may not be supported and/or execution might be slow. * <strong>NOTE: </strong>Strings will not be automatically split on whitespace. * * @param s * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria contains(String s) { assertNoBlankInWildcardedQuery(s, true, true); predicates.add(new Predicate(OperationKey.CONTAINS, s)); return this; } /** * Crates new {@link Predicate} with leading and trailing wildcards for each entry<br/> * <strong>NOTE: </strong>mind your schema as leading wildcards may not be supported and/or execution might be slow. * * @param values * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria contains(String... values) { assertValuesPresent((Object[]) values); return contains(Arrays.asList(values)); } /** * Crates new {@link Predicate} with leading and trailing wildcards for each entry<br/> * <strong>NOTE: </strong>mind your schema as leading wildcards may not be supported and/or execution might be slow. * * @param values * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria contains(Iterable<String> values) { Assert.notNull(values, "Collection must not be null"); for (String value : values) { contains(value); } return this; } /** * Crates new {@link Predicate} with trailing wildcard <br/> * <strong>NOTE: </strong>Strings will not be automatically split on whitespace. * * @param s * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria startsWith(String s) { assertNoBlankInWildcardedQuery(s, false, true); predicates.add(new Predicate(OperationKey.STARTS_WITH, s)); return this; } /** * Crates new {@link Predicate} with trailing wildcard for each entry * * @param values * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria startsWith(String... values) { assertValuesPresent((Object[]) values); return startsWith(Arrays.asList(values)); } /** * Crates new {@link Predicate} with trailing wildcard for each entry * * @param values * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria startsWith(Iterable<String> values) { Assert.notNull(values, "Collection must not be null"); for (String value : values) { startsWith(value); } return this; } /** * Crates new {@link Predicate} with leading wildcard <br /> * <strong>NOTE: </strong>mind your schema and execution times as leading wildcards may not be supported. * <strong>NOTE: </strong>Strings will not be automatically split on whitespace. * * @param s * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria endsWith(String s) { assertNoBlankInWildcardedQuery(s, true, false); predicates.add(new Predicate(OperationKey.ENDS_WITH, s)); return this; } /** * Crates new {@link Predicate} with leading wildcard for each entry<br /> * <strong>NOTE: </strong>mind your schema and execution times as leading wildcards may not be supported. * * @param values * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria endsWith(String... values) { assertValuesPresent((Object[]) values); return endsWith(Arrays.asList(values)); } /** * Crates new {@link Predicate} with leading wildcard for each entry<br /> * <strong>NOTE: </strong>mind your schema and execution times as leading wildcards may not be supported. * * @param values * @return * @throws InvalidDataAccessApiUsageException for strings with whitespace */ public Criteria endsWith(Iterable<String> values) { Assert.notNull(values, "Collection must not be null"); for (String value : values) { endsWith(value); } return this; } /** * Negates current criteria usinng {@code -} operator * * @return */ public Criteria not() { setNegating(true); return this; } /** * Explicitly wrap {@link Criteria} inside not operation. * * @since 1.4 * @return */ public Criteria notOperator() { Crotch c = new Crotch(); c.setNegating(true); c.add(this); return c; } /** * Crates new {@link Predicate} with trailing {@code ~} * * @param s * @return */ public Criteria fuzzy(String s) { return fuzzy(s, Float.NaN); } /** * Crates new {@link Predicate} with trailing {@code ~} followed by levensteinDistance * * @param s * @param levenshteinDistance * @return */ public Criteria fuzzy(String s, float levenshteinDistance) { if (!Float.isNaN(levenshteinDistance) && (levenshteinDistance < 0 || levenshteinDistance > 1)) { throw new InvalidDataAccessApiUsageException("Levenshtein Distance has to be within its bounds (0.0 - 1.0)."); } predicates.add(new Predicate(OperationKey.FUZZY, new Object[] { s, Float.valueOf(levenshteinDistance) })); return this; } /** * Crates new {@link Predicate} with trailing {@code ~} followed by distance * * @param phrase * @param distance * @return */ public Criteria sloppy(String phrase, int distance) { if (distance <= 0) { throw new InvalidDataAccessApiUsageException("Slop distance has to be greater than 0."); } if (!StringUtils.contains(phrase, CRITERIA_VALUE_SEPERATOR)) { throw new InvalidDataAccessApiUsageException("Phrase must consist of multiple terms, separated with spaces."); } predicates.add(new Predicate(OperationKey.SLOPPY, new Object[] { phrase, Integer.valueOf(distance) })); return this; } /** * Crates new {@link Predicate} allowing native solr expressions * * @param s * @return */ public Criteria expression(String s) { predicates.add(new Predicate(OperationKey.EXPRESSION, s)); return this; } /** * Boost positive hit with given factor. eg. ^2.3 * * @param boost * @return */ public Criteria boost(float boost) { if (boost < 0) { throw new InvalidDataAccessApiUsageException("Boost must not be negative."); } this.boost = boost; return this; } /** * Crates new {@link Predicate} for {@code RANGE [lowerBound TO upperBound]} * * @param lowerBound * @param upperBound * @return */ public Criteria between(Object lowerBound, Object upperBound) { return between(lowerBound, upperBound, true, true); } /** * Crates new {@link Predicate} for {@code RANGE [lowerBound TO upperBound]} * * @param lowerBound * @param upperBound * @param includeLowerBound * @param includeUppderBound * @return */ public Criteria between(Object lowerBound, Object upperBound, boolean includeLowerBound, boolean includeUppderBound) { predicates.add(new Predicate(OperationKey.BETWEEN, new Object[] { lowerBound, upperBound, includeLowerBound, includeUppderBound })); return this; } /** * Crates new {@link Predicate} for {@code RANGE [* TO upperBound}} * * @param upperBound * @return */ public Criteria lessThan(Object upperBound) { between(null, upperBound, true, false); return this; } /** * Crates new {@link Predicate} for {@code RANGE [* TO upperBound]} * * @param upperBound * @return */ public Criteria lessThanEqual(Object upperBound) { between(null, upperBound); return this; } /** * Crates new {@link Predicate} for {@code RANGE {lowerBound TO *]} * * @param lowerBound * @return */ public Criteria greaterThan(Object lowerBound) { between(lowerBound, null, false, true); return this; } /** * Crates new {@link Predicate} for {@code RANGE [lowerBound TO *]} * * @param lowerBound * @return */ public Criteria greaterThanEqual(Object lowerBound) { between(lowerBound, null); return this; } /** * Crates new {@link Predicate} for multiple values {@code (arg0 arg1 arg2 ...)} * * @param values * @return */ public Criteria in(Object... values) { assertValuesPresent(values); return (Criteria) in(Arrays.asList(values)); } /** * Crates new {@link Predicate} for multiple values {@code (arg0 arg1 arg2 ...)} * * @param values the collection containing the values to match against * @return */ public Criteria in(Iterable<?> values) { Assert.notNull(values, "Collection of 'in' values must not be null"); for (Object value : values) { if (value instanceof Collection) { in((Collection<?>) value); } else { is(value); } } return this; } /** * Creates new {@link Predicate} for {@code !geodist} * * @param location {@link Point} in degrees * @param distance * @return */ public Criteria within(Point location, Distance distance) { Assert.notNull(location, "Location must not be null!"); assertPositiveDistanceValue(distance); predicates.add( new Predicate(OperationKey.WITHIN, new Object[] { location, distance != null ? distance : new Distance(0) })); return this; } /** * Creates new {@link Predicate} for {@code !geodist}. * * @param circle * @return * @since 1.2 */ public Criteria within(Circle circle) { Assert.notNull(circle, "Circle for 'within' must not be 'null'."); return within(circle.getCenter(), circle.getRadius()); } /** * Creates new {@link Predicate} for {@code !bbox} with exact coordinates * * @param box * @return */ public Criteria near(Box box) { predicates.add(new Predicate(OperationKey.NEAR, new Object[] { box })); return this; } /** * Creates new {@link Predicate} for {@code !bbox} for a specified distance. The difference between this and * {@code within} is this is approximate while {@code within} is exact. * * @param location * @param distance * @return * @throws IllegalArgumentException if location is null * @throws InvalidDataAccessApiUsageException if distance is negative */ public Criteria near(Point location, Distance distance) { Assert.notNull(location, "Location must not be 'null' for near criteria."); assertPositiveDistanceValue(distance); predicates.add( new Predicate(OperationKey.NEAR, new Object[] { location, distance != null ? distance : new Distance(0) })); return this; } /** * Creates new {@link Predicate} for {@code !circle} for a specified distance. The difference between this and * {@link #within(Circle)} is this is approximate while {@code within} is exact. * * @param circle * @return * @since 1.2 */ public Criteria near(Circle circle) { Assert.notNull(circle, "Circle for 'near' must not be 'null'."); return near(circle.getCenter(), circle.getRadius()); } /** * Creates {@link Predicate} for given {@link Function}. * * @param function must not be null * @return * @throws IllegalArgumentException if function is null * @since 1.1 */ public Criteria function(Function function) { Assert.notNull(function, "Cannot add 'null' function to criteria."); predicates.add(new Predicate(OperationKey.FUNCTION, function)); return this; } /** * Target field * * @return null if not set */ public Field getField() { return this.field; } /** * @return true if {@code not()} criteria */ public boolean isNegating() { return super.isNegating(); } /** * Boost criteria value * * @return {@code Float.NaN} if not set */ public float getBoost() { return this.boost; } /** * @return unmodifiable set of all {@link Predicate} */ public Set<Predicate> getPredicates() { return Collections.unmodifiableSet(this.predicates); } private void assertPositiveDistanceValue(Distance distance) { if (distance != null && distance.getValue() < 0) { throw new InvalidDataAccessApiUsageException("distance must not be negative."); } } private void assertNoBlankInWildcardedQuery(String searchString, boolean leadingWildcard, boolean trailingWildcard) { if (StringUtils.contains(searchString, CRITERIA_VALUE_SEPERATOR)) { throw new InvalidDataAccessApiUsageException("Cannot constructQuery '" + (leadingWildcard ? "*" : "") + "\"" + searchString + "\"" + (trailingWildcard ? "*" : "") + "'. Use epxression or mulitple clauses instead."); } } private void assertValuesPresent(Object... values) { if (values.length == 0 || (values.length > 1 && values[1] instanceof Collection)) { throw new InvalidDataAccessApiUsageException( "At least one element " + (values.length > 0 ? ("of argument of type " + values[1].getClass().getName()) : "") + " has to be present."); } } @Override public String toString() { StringBuilder sb = new StringBuilder(this.isOr() ? "OR " : "AND "); sb.append(this.isNegating() ? "!" : ""); sb.append(this.field != null ? this.field.getName() : ""); if (this.predicates.size() > 1) { sb.append('('); } for (Predicate ce : this.predicates) { sb.append(ce.toString()); } if (this.predicates.size() > 1) { sb.append(')'); } sb.append(' '); return sb.toString(); } // -------- PREDICATE STUFF -------- public enum OperationKey { EQUALS("$equals"), CONTAINS("$contains"), STARTS_WITH("$startsWith"), ENDS_WITH("$endsWith"), EXPRESSION( "$expression"), BETWEEN( "$between"), NEAR("$near"), WITHIN("$within"), FUZZY("$fuzzy"), SLOPPY("$sloppy"), FUNCTION("$function"); private final String key; OperationKey(String key) { this.key = key; } public String getKey() { return this.key; } } /** * Single entry to be used when defining search criteria * * @author Christoph Strobl * @author Francisco Spaeth */ public static class Predicate { private String key; private Object value; public Predicate(OperationKey key, Object value) { this(key.getKey(), value); } public Predicate(String key, Object value) { this.key = key; this.value = value; } /** * @return null if not set */ public String getKey() { return key; } /** * set the operation key to be applied when parsing query * * @param key */ public void setKey(String key) { this.key = key; } /** * @return null if not set */ public Object getValue() { return value; } public void setValue(Object value) { this.value = value; } @Override public String toString() { return key + ":" + value; } } // -------- NODE STUFF --------- /** * Explicitly connect {@link Criteria} with another one allows to create explicit bracketing. * * @since 1.4 * @return */ public Criteria connect() { Crotch c = new Crotch(); c.add(this); return c; } @SuppressWarnings("unchecked") @Override public Crotch and(Node node) { if (!(node instanceof Criteria)) { throw new IllegalArgumentException("Can only add instances of Criteria"); } Crotch crotch = new Crotch(); crotch.setParent(this.getParent()); crotch.add(this); crotch.add((Criteria) node); return crotch; } @SuppressWarnings("unchecked") public Crotch and(String fieldname) { Criteria node = new Criteria(fieldname); return and(node); } @SuppressWarnings("unchecked") public Crotch or(Node node) { if (!(node instanceof Criteria)) { throw new IllegalArgumentException("Can only add instances of Criteria"); } node.setPartIsOr(true); Crotch crotch = new Crotch(); crotch.setParent(this.getParent()); crotch.add(this); crotch.add((Criteria) node); return crotch; } @SuppressWarnings("unchecked") public Crotch or(String fieldname) { Criteria node = new Criteria(fieldname); node.setPartIsOr(true); return or(node); } }