diff --git a/Parse/src/main/java/com/parse/OfflineQueryLogic.java b/Parse/src/main/java/com/parse/OfflineQueryLogic.java index 78b511589..dbb1e50ef 100644 --- a/Parse/src/main/java/com/parse/OfflineQueryLogic.java +++ b/Parse/src/main/java/com/parse/OfflineQueryLogic.java @@ -237,12 +237,24 @@ private static boolean matchesEqualConstraint(Object constraint, Object value) { return lhs.equals(rhs); } - return compare(constraint, value, new Decider() { - @Override - public boolean decide(Object constraint, Object value) { - return constraint.equals(value); - } - }); + Decider decider; + if (isStartsWithRegex(constraint)) { + decider = new Decider() { + @Override + public boolean decide(Object constraint, Object value) { + return ((String) value).matches(((KeyConstraints)constraint).get("$regex").toString()); + } + }; + } else { + decider = new Decider() { + @Override + public boolean decide(Object constraint, Object value) { + return constraint.equals(value); + } + }; + } + + return compare(constraint, value, decider); } /** @@ -348,6 +360,13 @@ private static boolean matchesAllConstraint(Object constraint, Object value) { } if (constraint instanceof Collection) { + if (isAnyValueRegexStartsWith((Collection) constraint)) { + constraint = cleanRegexStartsWith((Collection) constraint); + if (constraint == null) { + throw new IllegalArgumentException("All values in $all queries must be of starting with regex or non regex."); + } + } + for (Object requiredItem : (Collection) constraint) { if (!matchesEqualConstraint(requiredItem, value)) { return false; @@ -358,6 +377,79 @@ private static boolean matchesAllConstraint(Object constraint, Object value) { throw new IllegalArgumentException("Constraint type not supported for $all queries."); } + /** + * Check if any of the collection constraints is a regex to match strings that starts with another + * string. + */ + private static boolean isAnyValueRegexStartsWith(Collection constraints) { + for (Object constraint : constraints) { + if (isStartsWithRegex(constraint)) { + return true; + } + }; + + return false; + } + + /** + * Cleans all regex constraints. If any of the constraints is not a regex, then null is returned. + * All values in a $all constraint must be a starting with another string regex. + */ + private static Collection cleanRegexStartsWith(Collection constraints) { + ArrayList cleanedValues = new ArrayList<>(); + for (Object constraint : constraints) { + if (!(constraint instanceof KeyConstraints)) { + return null; + } + + KeyConstraints cleanedRegex = cleanRegexStartsWith((KeyConstraints) constraint); + if (cleanedRegex == null) { + return null; + } + + cleanedValues.add(cleanedRegex); + } + + return cleanedValues; + } + + /** + * Creates a regex pattern to match a substring at the beginning of another string. + * + * If given string is not a regex to match a string at the beginning of another string, then null + * is returned. + */ + private static KeyConstraints cleanRegexStartsWith(KeyConstraints regex) { + if (!isStartsWithRegex(regex)) { + return null; + } + + // remove all instances of \Q and \E from the remaining text & escape single quotes + String literalizedString = ((String)regex.get("$regex")) + .replaceAll("([^\\\\])(\\\\E)", "$1") + .replaceAll("([^\\\\])(\\\\Q)", "$1") + .replaceAll("^\\\\E", "") + .replaceAll("^\\\\Q", "") + .replaceAll("([^'])'", "$1''") + .replaceAll("^'([^'])", "''$1"); + + regex.put("$regex", literalizedString + ".*"); + return regex; + } + + /** + * Check if given constraint is a regex to match strings that starts with another string. + */ + private static boolean isStartsWithRegex(Object constraint) { + if (constraint == null || !(constraint instanceof KeyConstraints)) { + return false; + } + + KeyConstraints keyConstraints = (KeyConstraints) constraint; + return keyConstraints.size() == 1 && keyConstraints.containsKey("$regex") && + ((String)keyConstraints.get("$regex")).startsWith("^"); + } + /** * Matches $regex constraints. */ diff --git a/Parse/src/main/java/com/parse/ParseQuery.java b/Parse/src/main/java/com/parse/ParseQuery.java index 523d1e05c..aff5cc9fe 100644 --- a/Parse/src/main/java/com/parse/ParseQuery.java +++ b/Parse/src/main/java/com/parse/ParseQuery.java @@ -8,6 +8,8 @@ */ package com.parse; +import android.support.annotation.NonNull; + import org.json.JSONException; import org.json.JSONObject; @@ -1673,10 +1675,6 @@ public ParseQuery whereContainedIn(String key, Collection v } /** - * Add a constraint to the query that requires a particular key's value match another - * {@code ParseQuery}. - *

- * This only works on keys whose values are {@link ParseObject}s or lists of {@link ParseObject}s. * Add a constraint to the query that requires a particular key's value to contain every one of * the provided list of values. * @@ -1708,6 +1706,27 @@ public ParseQuery whereFullText(String key, String text) { return this; } + /** + * Add a constraint to the query that requires a particular key's value to contain each one of + * the provided list of strings entirely or just starting with given strings. + * + * @param key + * The key to check. This key's value must be an array. + * @param values + * The values that will match entirely or starting with them. + * @return this, so you can chain this call. + */ + public ParseQuery whereContainsAllStartsWith(String key, Collection values) { + ArrayList startsWithConstraints = new ArrayList<>(); + for (String value : values) { + KeyConstraints keyConstraints = new KeyConstraints(); + keyConstraints.put("$regex", buildStartsWithRegex(value)); + startsWithConstraints.add(keyConstraints); + } + + return whereContainsAll(key, startsWithConstraints); + } + /** * Add a constraint to the query that requires a particular key's value match another * {@code ParseQuery}. @@ -1988,7 +2007,7 @@ public ParseQuery whereContains(String key, String substring) { * @return this, so you can chain this call. */ public ParseQuery whereStartsWith(String key, String prefix) { - String regex = "^" + Pattern.quote(prefix); + String regex = buildStartsWithRegex(prefix); whereMatches(key, regex); return this; } @@ -2192,4 +2211,15 @@ public ParseQuery setTrace(boolean shouldTrace) { builder.setTracingEnabled(shouldTrace); return this; } + + /** + * Helper method to convert a string to regex for start word matching. + * + * @param prefix String to use as prefix in regex. + * @return The string converted as regex for start word matching. + */ + @NonNull + private String buildStartsWithRegex(String prefix) { + return "^" + Pattern.quote(prefix); + } } diff --git a/Parse/src/test/java/com/parse/OfflineQueryLogicTest.java b/Parse/src/test/java/com/parse/OfflineQueryLogicTest.java index f7d84c04f..309182adf 100644 --- a/Parse/src/test/java/com/parse/OfflineQueryLogicTest.java +++ b/Parse/src/test/java/com/parse/OfflineQueryLogicTest.java @@ -8,6 +8,8 @@ */ package com.parse; +import android.support.annotation.NonNull; + import org.json.JSONArray; import org.json.JSONObject; import org.junit.After; @@ -23,6 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import bolts.Task; @@ -419,6 +422,112 @@ public void testMatchesAll() throws Exception { assertFalse(matches(logic, query, object)); } + @Test + public void testMatchesAllStartingWith() throws Exception { + ParseObject object = new ParseObject("TestObject"); + object.put("foo", Arrays.asList("foo", "bar")); + + ParseQuery.State query; + OfflineQueryLogic logic = new OfflineQueryLogic(null); + + query = new ParseQuery.State.Builder<>("TestObject") + .addCondition("foo", "$all", + Arrays.asList( + buildStartsWithRegexKeyConstraint("foo"), + buildStartsWithRegexKeyConstraint("bar"))) + .build(); + assertTrue(matches(logic, query, object)); + + query = new ParseQuery.State.Builder<>("TestObject") + .addCondition("foo", "$all", + Arrays.asList( + buildStartsWithRegexKeyConstraint("fo"), + buildStartsWithRegexKeyConstraint("b"))) + .build(); + assertTrue(matches(logic, query, object)); + + query = new ParseQuery.State.Builder<>("TestObject") + .addCondition("foo", "$all", + Arrays.asList( + buildStartsWithRegexKeyConstraint("foo"), + buildStartsWithRegexKeyConstraint("bar"), + buildStartsWithRegexKeyConstraint("qux"))) + .build(); + assertFalse(matches(logic, query, object)); + + // Non-existant key + object = new ParseObject("TestObject"); + assertFalse(matches(logic, query, object)); + object.put("foo", JSONObject.NULL); + assertFalse(matches(logic, query, object)); + + thrown.expect(IllegalArgumentException.class); + object.put("foo", "bar"); + assertFalse(matches(logic, query, object)); + } + + @Test + public void testMatchesAllStartingWithParameters() throws Exception { + ParseObject object = new ParseObject("TestObject"); + object.put("foo", Arrays.asList("foo", "bar")); + + ParseQuery.State query; + OfflineQueryLogic logic = new OfflineQueryLogic(null); + + query = new ParseQuery.State.Builder<>("TestObject") + .addCondition("foo", "$all", + Arrays.asList( + buildStartsWithRegexKeyConstraint("foo"), + buildStartsWithRegexKeyConstraint("bar"))) + .build(); + assertTrue(matches(logic, query, object)); + + query = new ParseQuery.State.Builder<>("TestObject") + .addCondition("foo", "$all", + Arrays.asList( + buildStartsWithRegexKeyConstraint("fo"), + buildStartsWithRegex("ba"), + "b")) + .build(); + thrown.expect(IllegalArgumentException.class); + assertFalse(matches(logic, query, object)); + + query = new ParseQuery.State.Builder<>("TestObject") + .addCondition("foo", "$all", + Arrays.asList( + buildStartsWithRegexKeyConstraint("fo"), + "b")) + .build(); + thrown.expect(IllegalArgumentException.class); + assertFalse(matches(logic, query, object)); + } + + /** + * Helper method to convert a string to a key constraint to match strings that starts with given + * string. + * + * @param prefix String to use as prefix in regex. + * @return The key constraint for word matching at the beginning of a string. + */ + @NonNull + private ParseQuery.KeyConstraints buildStartsWithRegexKeyConstraint(String prefix) { + ParseQuery.KeyConstraints constraint = new ParseQuery.KeyConstraints(); + constraint.put("$regex", buildStartsWithRegex(prefix)); + return constraint; + } + + /** + * Helper method to convert a string to regex for start word matching. + * + * @param prefix String to use as prefix in regex. + * @return The string converted as regex for start word matching. + */ + @NonNull + private String buildStartsWithRegex(String prefix) { + return "^" + Pattern.quote(prefix); + } + + @Test public void testMatchesNearSphere() throws Exception { ParseGeoPoint fb = new ParseGeoPoint(37.481689f, -122.154949f); diff --git a/Parse/src/test/java/com/parse/ParseQueryTest.java b/Parse/src/test/java/com/parse/ParseQueryTest.java index f58aa3531..8c7aa4225 100644 --- a/Parse/src/test/java/com/parse/ParseQueryTest.java +++ b/Parse/src/test/java/com/parse/ParseQueryTest.java @@ -8,6 +8,8 @@ */ package com.parse; +import android.support.annotation.NonNull; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -389,6 +391,25 @@ public void testWhereContainsAll() throws Exception { verifyCondition(query, "key", "$all", values); } + @Test + public void testWhereContainsAllStartingWith() throws Exception { + ParseQuery query = new ParseQuery<>("Test"); + String value = "value"; + String valueAgain = "valueAgain"; + List values = Arrays.asList(value, valueAgain); + + ParseQuery.KeyConstraints valueConverted = new ParseQuery.KeyConstraints(); + valueConverted.put("$regex", buildStartsWithPattern(value)); + ParseQuery.KeyConstraints valueAgainConverted = new ParseQuery.KeyConstraints(); + valueAgainConverted.put("$regex", buildStartsWithPattern(valueAgain)); + List valuesConverted = + Arrays.asList(valueConverted, valueAgainConverted); + + query.whereContainsAllStartsWith("key", values); + + verifyCondition(query, "key", "$all", valuesConverted); + } + @Test public void testWhereNotContainedIn() throws Exception { ParseQuery query = new ParseQuery<>("Test"); @@ -425,7 +446,7 @@ public void testWhereStartsWith() throws Exception { String value = "prefix"; query.whereStartsWith("key", value); - verifyCondition(query, "key", "$regex", "^" + Pattern.quote(value)); + verifyCondition(query, "key", "$regex", buildStartsWithPattern(value)); } @Test @@ -904,4 +925,9 @@ public Task then(Task task) throws Exception { })).cast(); } } + + @NonNull + private String buildStartsWithPattern(String value) { + return "^" + Pattern.quote(value); + } }