Added support for quoted phrase search, string escaping in calendar provider
- We can now parse queries that include quoted phrases
- We also escape special characters like "%" and "_"
Change-Id: Iaf2519aa1b1af4753a2bf3b9bea342379186dac2
diff --git a/src/com/android/providers/calendar/CalendarProvider2.java b/src/com/android/providers/calendar/CalendarProvider2.java
index 11811bf..e656a26 100644
--- a/src/com/android/providers/calendar/CalendarProvider2.java
+++ b/src/com/android/providers/calendar/CalendarProvider2.java
@@ -64,8 +64,10 @@
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
import java.util.TimeZone;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
@@ -269,9 +271,29 @@
/**
* A regex for describing how we split search queries into tokens.
- * Currently splits on whitespace and punctuation
+ * Keeps quoted phrases as one token.
+ *
+ * "one \"two three\"" ==> ["one" "two three"]
*/
- private static final Pattern SEARCH_TOKEN_PATTERN = Pattern.compile("[\\s,.!?]+");
+ private static final Pattern SEARCH_TOKEN_PATTERN =
+ Pattern.compile("[^\\s\"'.?!,]+|" // first part matches unquoted words
+ + "\"([^\"]*)\""); // second part matches quoted phrases
+ /**
+ * A special character that was use to escape potentially problematic
+ * characters in search queries.
+ *
+ * Note: do not use backslash for this, as it interferes with the regex
+ * escaping mechanism.
+ */
+ private static final String SEARCH_ESCAPE_CHAR = "#";
+
+ /**
+ * A regex for matching any characters in an incoming search query that we
+ * need to escape with {@link #SEARCH_ESCAPE_CHAR}, including the escape
+ * character itself.
+ */
+ private static final Pattern SEARCH_ESCAPE_PATTERN =
+ Pattern.compile("([%_" + SEARCH_ESCAPE_CHAR + "])");
private static final String[] SEARCH_COLUMNS = new String[] {
Calendar.Events.TITLE,
@@ -860,16 +882,40 @@
}
/**
+ * Escape any special characters in the search token
+ * @param token the token to escape
+ * @return the escaped token
+ */
+ @VisibleForTesting
+ String escapeSearchToken(String token) {
+ Matcher matcher = SEARCH_ESCAPE_PATTERN.matcher(token);
+ return matcher.replaceAll(SEARCH_ESCAPE_CHAR + "$1");
+ }
+
+ /**
* Splits the search query into individual search tokens based on whitespace
- * and punctuation.
+ * and punctuation. Leaves both single quoted and double quoted strings
+ * intact.
*
- * TODO Support quoted phrases
* @param query the search query
* @return an array of tokens from the search query
*/
@VisibleForTesting
String[] tokenizeSearchQuery(String query) {
- return SEARCH_TOKEN_PATTERN.split(query);
+ List<String> matchList = new ArrayList<String>();
+ Matcher matcher = SEARCH_TOKEN_PATTERN.matcher(query);
+ String token;
+ while (matcher.find()) {
+ if (matcher.group(1) != null) {
+ // double quoted string
+ token = matcher.group(1);
+ } else {
+ // unquoted token
+ token = matcher.group();
+ }
+ matchList.add(escapeSearchToken(token));
+ }
+ return matchList.toArray(new String[matchList.size()]);
}
/**
@@ -902,7 +948,9 @@
sb.append("(");
for (int i = 0; i < SEARCH_COLUMNS.length; i++) {
sb.append(SEARCH_COLUMNS[i]);
- sb.append(" LIKE ? ");
+ sb.append(" LIKE ? ESCAPE \"");
+ sb.append(SEARCH_ESCAPE_CHAR);
+ sb.append("\" ");
if (i < SEARCH_COLUMNS.length - 1) {
sb.append("OR ");
}
@@ -934,10 +982,11 @@
qb.setTables(INSTANCE_QUERY_TABLES);
qb.setProjectionMap(sInstancesProjectionMap);
-
- String[] tokens = SEARCH_TOKEN_PATTERN.split(query);
+ String[] tokens = tokenizeSearchQuery(query);
String[] selectionArgs = constructSearchArgs(tokens, rangeBegin, rangeEnd);
- qb.appendWhere(constructSearchWhere(tokens));
+ String searchWhere = constructSearchWhere(tokens);
+
+ qb.appendWhere(searchWhere);
if (searchByDay) {
// Convert the first and last Julian day range to a range that uses
diff --git a/tests/src/com/android/providers/calendar/CalendarProvider2Test.java b/tests/src/com/android/providers/calendar/CalendarProvider2Test.java
index 438c334..d2ebde4 100644
--- a/tests/src/com/android/providers/calendar/CalendarProvider2Test.java
+++ b/tests/src/com/android/providers/calendar/CalendarProvider2Test.java
@@ -1270,15 +1270,50 @@
}
@SmallTest @Smoke
+ public void testEscapeSearchToken() {
+ String token = "test";
+ String expected = "test";
+ assertEquals(expected, mProvider.escapeSearchToken(token));
+
+ token = "%";
+ expected = "#%";
+ assertEquals(expected, mProvider.escapeSearchToken(token));
+
+ token = "_";
+ expected = "#_";
+ assertEquals(expected, mProvider.escapeSearchToken(token));
+
+ token = "#";
+ expected = "##";
+ assertEquals(expected, mProvider.escapeSearchToken(token));
+
+ token = "##";
+ expected = "####";
+ assertEquals(expected, mProvider.escapeSearchToken(token));
+
+ token = "%_#";
+ expected = "#%#_##";
+ assertEquals(expected, mProvider.escapeSearchToken(token));
+
+ token = "blah%blah";
+ expected = "blah#%blah";
+ assertEquals(expected, mProvider.escapeSearchToken(token));
+ }
+
+ @SmallTest @Smoke
public void testTokenizeSearchQuery() {
String query = "";
- String[] expectedTokens = new String[] {""};
+ String[] expectedTokens = new String[] {};
assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
query = "a";
expectedTokens = new String[] {"a"};
assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+ query = "word";
+ expectedTokens = new String[] {"word"};
+ assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
query = "two words";
expectedTokens = new String[] {"two", "words"};
assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
@@ -1286,13 +1321,38 @@
query = "test, punctuation.";
expectedTokens = new String[] {"test", "punctuation"};
assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+ query = "\"test phrase\"";
+ expectedTokens = new String[] {"test phrase"};
+ assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+ query = "unquoted \"this is quoted\"";
+ expectedTokens = new String[] {"unquoted", "this is quoted"};
+ assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+ query = " \"this is quoted\" unquoted ";
+ expectedTokens = new String[] {"this is quoted", "unquoted"};
+ assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+ query = "escap%e m_e";
+ expectedTokens = new String[] {"escap#%e", "m#_e"};
+ assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+ query = "'a bunch' of malformed\" things";
+ expectedTokens = new String[] {"a", "bunch", "of", "malformed", "things"};
+ assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+ query = "''''''....,.''trim punctuation";
+ expectedTokens = new String[] {"trim", "punctuation"};
+ assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
}
@SmallTest @Smoke
public void testConstructSearchWhere() {
String[] tokens = new String[] {"red"};
- String expected = "(title LIKE ? OR description LIKE ? OR eventLocation"
- + " LIKE ? ) AND ";
+ String expected = "(title LIKE ? ESCAPE \"#\" OR "
+ + "description LIKE ? ESCAPE \"#\" OR "
+ + "eventLocation LIKE ? ESCAPE \"#\" ) AND ";
assertEquals(expected, mProvider.constructSearchWhere(tokens));
tokens = new String[] {};
@@ -1300,15 +1360,21 @@
assertEquals(expected, mProvider.constructSearchWhere(tokens));
tokens = new String[] {"red", "green"};
- expected = "(title LIKE ? OR description LIKE ? OR eventLocation"
- + " LIKE ? ) AND (title LIKE ? OR description LIKE ? OR "
- + "eventLocation LIKE ? ) AND ";
+ expected = "(title LIKE ? ESCAPE \"#\" OR "
+ + "description LIKE ? ESCAPE \"#\" OR "
+ + "eventLocation LIKE ? ESCAPE \"#\" ) AND "
+ + "(title LIKE ? ESCAPE \"#\" OR "
+ + "description LIKE ? ESCAPE \"#\" OR "
+ + "eventLocation LIKE ? ESCAPE \"#\" ) AND ";
assertEquals(expected, mProvider.constructSearchWhere(tokens));
tokens = new String[] {"red blue", "green"};
- expected = "(title LIKE ? OR description LIKE ? OR eventLocation"
- + " LIKE ? ) AND (title LIKE ? OR description LIKE ? OR "
- + "eventLocation LIKE ? ) AND ";
+ expected = "(title LIKE ? ESCAPE \"#\" OR "
+ + "description LIKE ? ESCAPE \"#\" OR "
+ + "eventLocation LIKE ? ESCAPE \"#\" ) AND "
+ + "(title LIKE ? ESCAPE \"#\" OR "
+ + "description LIKE ? ESCAPE \"#\" OR "
+ + "eventLocation LIKE ? ESCAPE \"#\" ) AND ";
assertEquals(expected, mProvider.constructSearchWhere(tokens));
}
@@ -1458,6 +1524,42 @@
cursor.close();
}
}
+
+ try {
+ cursor = Instances.query(mResolver, PROJECTION,
+ startMs - DateUtils.YEAR_IN_MILLIS,
+ startMs + DateUtils.HOUR_IN_MILLIS,
+ "\"search purple\"", where, orderBy);
+ assertEquals(1, cursor.getCount());
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ try {
+ cursor = Instances.query(mResolver, PROJECTION,
+ startMs - DateUtils.YEAR_IN_MILLIS,
+ startMs + DateUtils.HOUR_IN_MILLIS,
+ "\"purple search\"", where, orderBy);
+ assertEquals(0, cursor.getCount());
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ try {
+ cursor = Instances.query(mResolver, PROJECTION,
+ startMs - DateUtils.YEAR_IN_MILLIS,
+ startMs + DateUtils.HOUR_IN_MILLIS,
+ "%", where, orderBy);
+ assertEquals(0, cursor.getCount());
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
}
public void testEntityQuery() throws Exception {