Implement Selection

Selection can be used to make complex SQL queries more readable, and pass around incomplete selections for appending.

See cl/162013087 for usage

Test: SelectionTest
PiperOrigin-RevId: 164618236
Change-Id: Ice035211f6b02858255a9e7d18215c9282bf28e9
diff --git a/java/com/android/dialer/common/database/Selection.java b/java/com/android/dialer/common/database/Selection.java
new file mode 100644
index 0000000..b61472d
--- /dev/null
+++ b/java/com/android/dialer/common/database/Selection.java
@@ -0,0 +1,260 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * 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 com.android.dialer.common.database;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import com.android.dialer.common.Assert;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Utility to build SQL selections. Handles string concatenation, nested statements, empty
+ * statements, and tracks the selection arguments.
+ *
+ * <p>A selection can be build from a string, factory methods like {@link #column(String)}, or use
+ * {@link Builder} to build complex nested selection with multiple operators. The Selection manages
+ * the {@code selection} and {@code selectionArgs} passed into {@link
+ * android.content.ContentResolver#query(android.net.Uri, String[], String, String[], String)}.
+ *
+ * <p>Example:
+ *
+ * <pre><code>
+ *   fromString("foo = 1")
+ * </code></pre>
+ *
+ * expands into "(foo = 1)", {}
+ *
+ * <p>
+ *
+ * <pre><code>
+ *   column("foo").is("LIKE", "bar")
+ * </code></pre>
+ *
+ * expands into "(foo LIKE ?)", {"bar"}
+ *
+ * <p>
+ *
+ * <pre><code>
+ *   builder()
+ *     .and(
+ *       fromString("foo = ?", "1").buildUpon()
+ *       .or(column("bar").is("<", 2))
+ *       .build())
+ *     .and(not(column("baz").is("!= 3")))
+ *     .build();
+ * </code></pre>
+ *
+ * expands into "(((foo = ?) OR (bar < ?)) AND (NOT (baz != 3)))", {"1", "2"}
+ */
+public final class Selection {
+
+  private final String selection;
+  private final String[] selectionArgs;
+
+  private Selection(@NonNull String selection, @NonNull String[] selectionArgs) {
+    this.selection = selection;
+    this.selectionArgs = selectionArgs;
+  }
+
+  @NonNull
+  public String getSelection() {
+    return selection;
+  }
+
+  @NonNull
+  public String[] getSelectionArgs() {
+    return selectionArgs;
+  }
+
+  public boolean isEmpty() {
+    return selection.isEmpty();
+  }
+
+  /**
+   * @return a mutable builder that appends to the selection. The selection will be parenthesized
+   *     before anything is appended to it.
+   */
+  @NonNull
+  public Builder buildUpon() {
+    return new Builder(this);
+  }
+
+  /** @return a builder that is empty. */
+  @NonNull
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /**
+   * @return a Selection built from regular selection string/args pair. The result selection will be
+   *     enclosed in a parenthesis.
+   */
+  @NonNull
+  public static Selection fromString(@Nullable String selection, @Nullable String... args) {
+    return new Builder(selection, args).build();
+  }
+
+  /** @return a selection that is negated */
+  @NonNull
+  public static Selection not(@NonNull Selection selection) {
+    Assert.checkArgument(!selection.isEmpty());
+    return fromString("NOT " + selection.getSelection(), selection.getSelectionArgs());
+  }
+
+  /**
+   * Build a selection based on condition upon a column. is() should be called to complete the
+   * selection.
+   */
+  @NonNull
+  public static Column column(@NonNull String column) {
+    return new Column(column);
+  }
+
+  /** Helper class to build a selection based on condition upon a column. */
+  public static class Column {
+
+    @NonNull private final String column;
+
+    private Column(@NonNull String column) {
+      this.column = Assert.isNotNull(column);
+    }
+
+    /** Expands to "<column> <operator> ?" and add {@code value} to the arguments. */
+    @NonNull
+    public Selection is(@NonNull String operator, @NonNull Object value) {
+      return fromString(column + " " + Assert.isNotNull(operator) + " ?", value.toString());
+    }
+
+    /**
+     * Expands to "<column> <operator>". {@link #is(String, Object)} should be used if the condition
+     * is comparing to a string or a user input value, which must be sanitized.
+     */
+    @NonNull
+    public Selection is(@NonNull String condition) {
+      return fromString(column + " " + Assert.isNotNull(condition));
+    }
+  }
+
+  /** Builder for {@link Selection} */
+  public static final class Builder {
+
+    private final StringBuilder selection = new StringBuilder();
+    private final List<String> selectionArgs = new ArrayList<>();
+
+    private Builder() {}
+
+    private Builder(@Nullable String selection, @Nullable String... args) {
+      if (selection == null) {
+        return;
+      }
+      checkArgsCount(selection, args);
+      this.selection.append(parenthesized(selection));
+      if (args != null) {
+        Collections.addAll(selectionArgs, args);
+      }
+    }
+
+    private Builder(@NonNull Selection selection) {
+      this.selection.append(selection.getSelection());
+      Collections.addAll(selectionArgs, selection.selectionArgs);
+    }
+
+    @NonNull
+    public Selection build() {
+      if (selection.length() == 0) {
+        return new Selection("", new String[] {});
+      }
+      return new Selection(
+          parenthesized(selection.toString()),
+          selectionArgs.toArray(new String[selectionArgs.size()]));
+    }
+
+    @NonNull
+    public Builder and(@NonNull Selection selection) {
+      if (selection.isEmpty()) {
+        return this;
+      }
+
+      if (this.selection.length() > 0) {
+        this.selection.append(" AND ");
+      }
+      this.selection.append(selection.getSelection());
+      Collections.addAll(selectionArgs, selection.getSelectionArgs());
+      return this;
+    }
+
+    @NonNull
+    public Builder or(@NonNull Selection selection) {
+      if (selection.isEmpty()) {
+        return this;
+      }
+
+      if (this.selection.length() > 0) {
+        this.selection.append(" OR ");
+      }
+      this.selection.append(selection.getSelection());
+      Collections.addAll(selectionArgs, selection.getSelectionArgs());
+      return this;
+    }
+
+    private static void checkArgsCount(@NonNull String selection, @Nullable String... args) {
+      int argsInSelection = 0;
+      for (int i = 0; i < selection.length(); i++) {
+        if (selection.charAt(i) == '?') {
+          argsInSelection++;
+        }
+      }
+      Assert.checkArgument(argsInSelection == (args == null ? 0 : args.length));
+    }
+  }
+
+  /**
+   * Parenthesized the {@code string}. Will not parenthesized if {@code string} is empty or is
+   * already parenthesized (top level parenthesis encloses the whole string).
+   */
+  @NonNull
+  private static String parenthesized(@NonNull String string) {
+    if (string.isEmpty()) {
+      return "";
+    }
+    if (!string.startsWith("(")) {
+      return "(" + string + ")";
+    }
+    int depth = 1;
+    for (int i = 1; i < string.length() - 1; i++) {
+      switch (string.charAt(i)) {
+        case '(':
+          depth++;
+          break;
+        case ')':
+          depth--;
+          if (depth == 0) {
+            // First '(' closed before the string has ended,need an additional level of nesting.
+            // For example "(A) AND (B)" should become "((A) AND (B))"
+            return "(" + string + ")";
+          }
+          break;
+        default:
+          continue;
+      }
+    }
+    Assert.checkArgument(depth == 1);
+    return string;
+  }
+}